create-nevela 0.1.2 → 0.1.4

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
@@ -16,14 +16,14 @@ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
16
16
  my-app/
17
17
  ├─ apps/api/ a Laravel app with Sanctum and Nevela installed
18
18
  ├─ apps/web/ the Next.js dashboard, pointed at the API
19
- └─ scripts/ `dev` runs both together
19
+ └─ nevela every command, from this folder: php nevela
20
20
  ```
21
21
 
22
22
  Then:
23
23
 
24
24
  ```sh
25
25
  cd my-app
26
- pnpm run dev
26
+ php nevela dev
27
27
  ```
28
28
 
29
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`.
@@ -42,7 +42,15 @@ That writes the Laravel code and the dashboard pages, and creates the table. `ph
42
42
  php nevela update
43
43
  ```
44
44
 
45
- That updates the Laravel package, regenerates, and updates the dashboard files you have not changed. See https://nevela-docs.vercel.app/guides/updating/.
45
+ That updates the Laravel package, regenerates, and updates the dashboard files you have not changed. Files you changed are kept, and everything it replaces is backed up first.
46
+
47
+ If `php` is not a command in your shell (Git Bash with Laravel Herd), or the app is an old one, run it through this package instead. It works in any shell and on an app of any age:
48
+
49
+ ```sh
50
+ npx create-nevela update
51
+ ```
52
+
53
+ Installed globally (`npm install -g create-nevela`), it gives you a `nevela` command: `nevela update`, `nevela dev`, `nevela status`, `nevela resource …`. See https://nevela-docs.vercel.app/guides/updating/.
46
54
 
47
55
  ## Options
48
56
 
@@ -50,6 +58,7 @@ That updates the Laravel package, regenerates, and updates the dashboard files y
50
58
  |---|---|
51
59
  | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager for the dashboard. Default: the one you ran this with. |
52
60
  | `--no-install` | Don't install the dashboard's dependencies. |
61
+ | `--fast` | Leave out PHPUnit, Pint and Laravel's other development packages. About a third quicker. |
53
62
  | `--no-user` | Don't create the starter account. |
54
63
  | `--no-git` | Don't run `git init`. |
55
64
  | `--bundled-package` | Use the copy of `nevela/laravel` inside this installer instead of the release on Packagist. |
package/index.mjs CHANGED
@@ -6,19 +6,21 @@
6
6
  //
7
7
  // No dependencies on purpose: this runs before anything is installed.
8
8
 
9
- import { spawn, spawnSync } from 'node:child_process';
9
+ import { spawn } from 'node:child_process';
10
10
  import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import readline from 'node:readline/promises';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { isNewer, onPackagist } from './packagist.mjs';
16
- import { copyLaravelPackage, copyWebTemplate } from './template.mjs';
15
+ import { latestRelease, onPackagist } from './packagist.mjs';
16
+ import { COMMANDS, findProject, forward, update } from './project.mjs';
17
+ import { shellLine, spawnCommand, windows } from './shell.mjs';
18
+ import { newerVersion, runVersion } from './selfupdate.mjs';
19
+ import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
17
20
 
18
21
  const here = path.dirname(fileURLToPath(import.meta.url));
19
22
  const VERSION = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version;
20
23
  const DOCS = 'https://nevela-docs.vercel.app';
21
- const windows = process.platform === 'win32';
22
24
 
23
25
  /**
24
26
  * The account every new app starts with, so there is something to sign in as straight
@@ -39,14 +41,23 @@ const HELP = `
39
41
 
40
42
  Create a new Nevela app: a Laravel API and a Next.js dashboard.
41
43
 
42
- ${bold('Usage')}
44
+ ${bold('Create an app')}
43
45
  pnpm create nevela <name> [options]
44
46
  npm create nevela@latest <name> -- [options]
45
47
 
48
+ ${bold('Inside an app')} (also as: nevela <command>, once installed with npm install -g create-nevela)
49
+ nevela update Update Nevela and the dashboard to the latest version
50
+ nevela dev Run the API and the dashboard
51
+ nevela status Check versions, migrations, users and the dashboard
52
+ nevela resource <Name> --fields="…" [--seed]
53
+ nevela seed | user | generate | version | migrate | artisan <command>
54
+
46
55
  ${bold('Options')}
47
56
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
48
57
  --no-install Don't install the dashboard's dependencies.
49
58
  --no-user Don't create the starter admin account.
59
+ --fast Leave out PHPUnit, Pint and Laravel's other development packages.
60
+ About a third quicker. Add them later: cd apps/api && composer install
50
61
  --no-git Don't run git init.
51
62
  --bundled-package Use the copy of nevela/laravel that ships with this installer
52
63
  instead of the release on Packagist.
@@ -70,27 +81,12 @@ function run(command, args, { cwd, interactive = false, allowFailure = false } =
70
81
  const result = spawnCommand(command, args, { cwd, stdio: interactive ? 'inherit' : 'pipe', encoding: 'utf8' });
71
82
  const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
72
83
  if (result.error || result.status !== 0) {
73
- if (allowFailure) return { ok: false, output };
84
+ if (allowFailure) return { ok: false, output, status: result.status };
74
85
  fail(`\`${command} ${args.join(' ')}\` failed.`, output || String(result.error ?? ''));
75
86
  }
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
+ return { ok: true, output, status: 0 };
87
88
  }
88
89
 
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
90
  /**
95
91
  * Start a command and carry on. Its output goes to a file rather than a pipe: the steps
96
92
  * that follow block this process for minutes, and a pipe nobody is reading fills up and
@@ -119,6 +115,33 @@ function start(command, args, { cwd }) {
119
115
  return { done };
120
116
  }
121
117
 
118
+ /**
119
+ * Run a command without blocking, handing each line of its output to `onLine`. For the one
120
+ * long step, so there is something to show while it works. Fails like run() does.
121
+ */
122
+ function runLive(command, args, { cwd, onLine }) {
123
+ return new Promise((resolve) => {
124
+ const child = windows
125
+ ? spawn(shellLine(command, args), { cwd, shell: true, stdio: ['ignore', 'pipe', 'pipe'] })
126
+ : spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
127
+ let output = '';
128
+ let rest = '';
129
+ const read = (chunk) => {
130
+ output += chunk;
131
+ const lines = (rest + chunk).split(/\r?\n/);
132
+ rest = lines.pop();
133
+ for (const line of lines) onLine?.(line);
134
+ };
135
+ child.stdout.setEncoding('utf8').on('data', read);
136
+ child.stderr.setEncoding('utf8').on('data', read);
137
+ child.on('error', (error) => fail(`\`${command} ${args.join(' ')}\` failed.`, String(error)));
138
+ child.on('close', (code) => {
139
+ if (code !== 0) fail(`\`${command} ${args.join(' ')}\` failed.`, output);
140
+ resolve(output);
141
+ });
142
+ });
143
+ }
144
+
122
145
  function available(command, args = ['--version']) {
123
146
  const result = spawnCommand(command, args, { stdio: 'pipe', encoding: 'utf8' });
124
147
  return result.status === 0 ? `${result.stdout}${result.stderr}` : null;
@@ -130,17 +153,21 @@ async function step(label, work) {
130
153
  const started = Date.now();
131
154
  // The "in progress" line is rewritten in place when the step ends. That only works if
132
155
  // 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);
156
+ const inPlace = process.stdout.isTTY && label.length + 30 < (process.stdout.columns ?? 80);
134
157
  if (inPlace) process.stdout.write(` ${dim('◇')} ${label}…`);
135
- const result = await work();
158
+ // A step may say how far along it is; shown beside the label, rewritten in place.
159
+ const progress = (text) => {
160
+ if (inPlace) process.stdout.write(`\r ${dim('◇')} ${label}… ${dim(text)} `);
161
+ };
162
+ const result = await work(progress);
136
163
  const seconds = result?.seconds ?? (Date.now() - started) / 1000;
137
164
  const took = seconds >= 1 ? dim(` (${duration(seconds)})`) : '';
138
- process.stdout.write(`${inPlace ? '\r' : ''} ${green('✔')} ${label}${took} \n`);
165
+ process.stdout.write(`${inPlace ? '\r' : ''} ${green('✔')} ${label}${took}${' '.repeat(24)}\n`);
139
166
  return result;
140
167
  }
141
168
 
142
169
  function parseArgs(argv) {
143
- const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false };
170
+ const options = { name: undefined, pm: undefined, install: true, user: true, git: true, yes: false, bundledPackage: false, devTools: true };
144
171
  for (let i = 0; i < argv.length; i++) {
145
172
  const arg = argv[i];
146
173
  if (arg === '-h' || arg === '--help') {
@@ -153,6 +180,7 @@ function parseArgs(argv) {
153
180
  else if (arg === '--no-install') options.install = false;
154
181
  else if (arg === '--no-user') options.user = false;
155
182
  else if (arg === '--no-git') options.git = false;
183
+ else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
156
184
  else if (arg === '--bundled-package') options.bundledPackage = true;
157
185
  else if (arg === '--pm') options.pm = argv[++i];
158
186
  else if (arg.startsWith('--pm=')) options.pm = arg.slice(5);
@@ -163,20 +191,27 @@ function parseArgs(argv) {
163
191
  return options;
164
192
  }
165
193
 
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(' ');
194
+ /**
195
+ * Whether typing `php` works in the shell this was started from. In Git Bash it doesn't
196
+ * when PHP is a php.bat (Laravel Herd): bash won't run a .bat for the bare name. Windows'
197
+ * own shells do, and so does this installer, which is why it got this far.
198
+ */
199
+ function phpIsTypeable() {
200
+ if (!windows || !process.env.MSYSTEM) return true;
201
+ const found = spawnCommand('where', ['php'], { stdio: 'pipe', encoding: 'utf8' });
202
+ const first = `${found.stdout ?? ''}`.split(/\r?\n/)[0].trim().toLowerCase();
203
+ return first.endsWith('.exe');
204
+ }
168
205
 
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
- }
206
+ /** `nevela <command>` as someone should type it here: through PHP, or through the package manager. */
207
+ function nevelaCommand(pm) {
208
+ if (phpIsTypeable()) return 'php nevela';
209
+ return { pnpm: 'pnpm nevela', yarn: 'yarn nevela', bun: 'bun run nevela' }[pm] ?? 'npm run nevela';
178
210
  }
179
211
 
212
+ /** "my-shop" → "My Shop", for the dashboard's title. */
213
+ const titleCase = (slug) => slug.split(/[-_]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
214
+
180
215
  function detectPackageManager(requested) {
181
216
  const known = ['pnpm', 'npm', 'yarn', 'bun'];
182
217
  if (requested) {
@@ -227,7 +262,7 @@ function writeRootFiles(root, { name, title, pm, admin }) {
227
262
  private: true,
228
263
  type: 'module',
229
264
  scripts: {
230
- dev: 'node scripts/dev.mjs',
265
+ dev: 'php nevela dev',
231
266
  nevela: 'php nevela',
232
267
  'dev:api': 'cd apps/api && php artisan serve',
233
268
  'dev:web': `cd apps/web && ${pm} run dev`,
@@ -236,50 +271,6 @@ function writeRootFiles(root, { name, title, pm, admin }) {
236
271
 
237
272
  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
273
 
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
274
  write(path.join(root, 'README.md'), `# ${title}
284
275
 
285
276
  A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard in \`apps/web\`.
@@ -287,10 +278,16 @@ A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard i
287
278
  ## Run it
288
279
 
289
280
  \`\`\`sh
290
- ${pm} run dev
281
+ php nevela dev
291
282
  \`\`\`
292
283
 
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.
284
+ That starts the Laravel API and the dashboard together and prints where each is: normally http://127.0.0.1:8000 and http://localhost:3000. If another program already has a port, it uses the next free one. Sign in at http://localhost:3000/sign-in.
285
+
286
+ To check the app at any time (versions, migrations, users, and whether the dashboard is reaching this API):
287
+
288
+ \`\`\`sh
289
+ php nevela status
290
+ \`\`\`
294
291
  ${admin ? `
295
292
  The app starts with one account, in your local database only:
296
293
 
@@ -303,6 +300,7 @@ Everything else runs from this folder too, with \`php nevela\`:
303
300
 
304
301
  \`\`\`sh
305
302
  php nevela user # add someone who can sign in
303
+ php nevela status # versions, migrations, users, and what the dashboard is talking to
306
304
  php nevela update # update Nevela and the dashboard
307
305
  php nevela # everything it can do
308
306
  \`\`\`
@@ -323,18 +321,51 @@ Docs: ${DOCS}
323
321
  `);
324
322
  }
325
323
 
324
+ /** `nevela <command>` inside an existing app. Never returns. */
325
+ async function inProject(name, args) {
326
+ const project = findProject(process.cwd());
327
+ if (!project) {
328
+ fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: pnpm create nevela my-app`);
329
+ }
330
+ if (name === 'version') console.log(`create-nevela ${VERSION}`);
331
+ if (name !== 'update') process.exit(forward(project, name, args));
332
+
333
+ // Updating is the one command that must not run on stale code itself.
334
+ if (!process.env.NEVELA_NO_SELF_UPDATE && !fromRepository) {
335
+ const newer = await newerVersion(VERSION);
336
+ if (newer) {
337
+ const status = await runVersion(newer, ['update', ...args]);
338
+ if (status !== null) process.exit(status);
339
+ }
340
+ }
341
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
342
+ const releases = await latestRelease();
343
+ const status = await update(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
344
+ process.exit(status);
345
+ }
346
+
326
347
  async function main() {
327
- const options = parseArgs(process.argv.slice(2));
348
+ const argv = process.argv.slice(2);
349
+ if (argv[0] === 'new') argv.shift();
350
+ else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
351
+ const options = parseArgs(argv);
328
352
 
329
353
  console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
330
354
 
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);
355
+ // A package manager may hand over an older installer than the newest: pnpm skips versions
356
+ // published in the last few hours, and both pnpm and npm cache. Fetch the newest and let
357
+ // it do the work. Skipped when running from the repository, which is always its own version.
358
+ let outdated = null;
359
+ if (!process.env.NEVELA_NO_SELF_UPDATE && !fromRepository) {
360
+ const newer = await newerVersion(VERSION);
361
+ if (newer) {
362
+ console.log(` ${dim(`Your package manager served ${VERSION}. Fetching ${newer}, the latest…`)}`);
363
+ const status = await runVersion(newer, argv);
364
+ if (status !== null) process.exit(status);
365
+ outdated = ` ${red('!')} Couldn't fetch create-nevela ${bold(newer)}, so this is ${VERSION}. To get it: ${bold(`npm create nevela@latest ${options.name ?? 'my-app'}`)}\n`;
366
+ console.log(outdated);
367
+ }
368
+ }
338
369
 
339
370
  let name = options.name;
340
371
  if (!name) {
@@ -367,41 +398,82 @@ async function main() {
367
398
  // Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
368
399
  write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
369
400
  }
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`);
401
+ // Which dashboard this app started from, with a fingerprint of every file in it.
402
+ // `nevela update` compares against these to tell the files you changed from the ones
403
+ // you didn't, so an update never overwrites your work.
404
+ write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
373
405
  writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
374
406
  const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
375
407
 
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
- });
408
+ const tokens = await step('Creating the Laravel app', async (progress) => {
409
+ // The skeleton only. Its packages are installed below, once, together with Nevela's.
410
+ run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-install', '--no-scripts', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
379
411
 
380
- const tokens = await step('Installing Nevela and token sign-in', async () => {
381
412
  const wanted = VERSION.split('.').slice(0, 2).map(Number);
382
- let nevela = `nevela/laravel:^${wanted.join('.')}`;
413
+ let nevela = `^${wanted.join('.')}`;
414
+ const manifestFile = path.join(api, 'composer.json');
415
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
416
+ // Laravel asks Composer for an "optimized" autoloader, which means reading every file
417
+ // of every package to index it. On a fresh install each of those ~9,000 files is being
418
+ // read for the first time, and antivirus software scans each one: that single step
419
+ // took over five minutes on Windows. An app in development doesn't need the index.
420
+ // Production gets it with `composer install --no-dev --optimize-autoloader`.
421
+ manifest.config = { ...manifest.config, 'optimize-autoloader': false };
383
422
  if (options.bundledPackage || !(await onPackagist(wanted))) {
384
423
  // No matching release on Packagist (or --bundled-package): the copy that travels with
385
424
  // this installer is placed in the app and installed by path.
386
425
  copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
387
- run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
388
- nevela = 'nevela/laravel:@dev';
426
+ manifest.repositories = [...(manifest.repositories ?? []), { type: 'path', url: '../../packages/nevela-laravel' }];
427
+ nevela = '@dev';
389
428
  }
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 });
429
+ // Sanctum and Nevela go into composer.json here, so the one install below does
430
+ // everything. (`php artisan install:api` would add Sanctum in a Composer run of its own,
431
+ // plus a routes/api.php this app doesn't use: Nevela registers its routes itself.)
432
+ manifest.require = { ...manifest.require, 'laravel/sanctum': '^4.0', 'nevela/laravel': nevela };
433
+ fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 4)}\n`);
434
+
435
+ let total = 0;
436
+ let done = 0;
437
+ await runLive('composer', ['install', '--no-interaction', '--no-progress', '--no-scripts', ...(options.devTools ? [] : ['--no-dev'])], {
438
+ cwd: api,
439
+ onLine: (line) => {
440
+ const operations = /Package operations: (\d+) installs?/.exec(line);
441
+ if (operations) total = Number(operations[1]);
442
+ if (/- Installing .*Extracting archive|- Installing .*Symlinking|- Installing .*Junctioning/.test(line)) done++;
443
+ if (total) progress(`${Math.min(done, total)}/${total} packages`);
444
+ },
445
+ });
446
+
447
+ // What Composer's create-project scripts would have done, without starting Composer
448
+ // again for each: the .env file, the SQLite database and the app key. The first
449
+ // artisan command is slow for the same reason the index was: files read for the first time.
450
+ const env = path.join(api, '.env');
451
+ if (!fs.existsSync(env) && fs.existsSync(`${env}.example`)) fs.copyFileSync(`${env}.example`, env);
452
+ const sqlite = path.join(api, 'database', 'database.sqlite');
453
+ if (/^DB_CONNECTION=sqlite\s*$/m.test(fs.readFileSync(env, 'utf8')) && !fs.existsSync(sqlite)) fs.writeFileSync(sqlite, '');
394
454
  return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
395
455
  });
396
456
 
397
457
  let admin = false;
398
458
  await step('Setting up the database', () => {
399
- // Writes routes/nevela.php and the dashboard's (empty) resource registry.
459
+ // One artisan process for the app key, Sanctum's migration, the generated files, the
460
+ // migrations and the starter account: each separate call would start Laravel again.
461
+ const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`] : [];
462
+ const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
463
+ if (setup.ok) {
464
+ admin = account.length > 0;
465
+ return;
466
+ }
467
+ // 2: the app is set up and only the starter account could not be created.
468
+ if (setup.status === 2) return;
469
+ if (!/"nevela:setup" is not defined/.test(setup.output)) fail('Setting up the database failed.', setup.output);
470
+
471
+ // A nevela/laravel from before `nevela:setup` existed: the same steps, one at a time.
472
+ run('php', ['artisan', 'key:generate', '--force', '--no-interaction'], { cwd: api });
473
+ run('php', ['artisan', 'vendor:publish', '--tag=sanctum-migrations', '--no-interaction'], { cwd: api });
400
474
  run('php', ['artisan', 'nevela:generate'], { cwd: api });
401
475
  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
- }
476
+ if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account], { cwd: api, allowFailure: true }).ok;
405
477
  });
406
478
 
407
479
  let installed = false;
@@ -419,22 +491,29 @@ async function main() {
419
491
  console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
420
492
  console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
421
493
  }
494
+ if (!options.devTools) {
495
+ console.log(` ${dim('PHPUnit, Pint and the other development packages were left out. Add them: cd apps/api && composer install')}\n`);
496
+ }
422
497
  if (packages && !installed) {
423
498
  console.log(` ${red('!')} The dashboard's packages didn't install. Run it yourself to see why: cd ${name}/apps/web && ${pm} install\n`);
424
499
  }
425
500
  console.log(' Next:\n');
426
501
  console.log(` cd ${name}`);
427
502
  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`);
503
+ const nevela = nevelaCommand(pm);
504
+ if (!admin) console.log(` ${nevela} user ${dim('# someone to sign in as')}`);
505
+ console.log(` ${nevela} dev\n`);
430
506
  console.log(` Then open ${indigo('http://localhost:3000/sign-in')}${admin ? ' and sign in with:' : ''}`);
431
507
  if (admin) {
432
508
  console.log(`\n Email ${bold(ADMIN.email)}`);
433
509
  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')}`);
510
+ console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
511
+ }
512
+ console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
513
+ console.log(` Check the app: ${dim(`${nevela} status`)} Update it later: ${dim(`${nevela} update`)} Everything: ${dim(nevela)}`);
514
+ if (nevela !== 'php nevela') {
515
+ console.log(`\n ${dim(`In this shell "php" isn't a command (your PHP is php.bat), so use ${nevela}. In PowerShell, php nevela works too.`)}`);
435
516
  }
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
517
  console.log(` Docs: ${DOCS}/start/quickstart/\n`);
439
518
  if (outdated) console.log(outdated);
440
519
  }
package/package.json CHANGED
@@ -1,37 +1,41 @@
1
- {
2
- "name": "create-nevela",
3
- "version": "0.1.2",
4
- "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
- "type": "module",
6
- "bin": {
7
- "create-nevela": "index.mjs"
8
- },
9
- "files": [
10
- "index.mjs",
11
- "template.mjs",
12
- "packagist.mjs",
13
- "template",
14
- "LICENSE"
15
- ],
16
- "scripts": {
17
- "prepack": "node build-template.mjs"
18
- },
19
- "engines": {
20
- "node": ">=20"
21
- },
22
- "keywords": [
23
- "nevela",
24
- "laravel",
25
- "nextjs",
26
- "scaffold",
27
- "create"
28
- ],
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/MarkColeMukisa/nevela.git",
32
- "directory": "packages/create-nevela"
33
- },
34
- "homepage": "https://nevela-docs.vercel.app",
35
- "license": "MIT",
36
- "author": "Mark Cole MUKISA"
37
- }
1
+ {
2
+ "name": "create-nevela",
3
+ "version": "0.1.4",
4
+ "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-nevela": "index.mjs",
8
+ "nevela": "index.mjs"
9
+ },
10
+ "files": [
11
+ "index.mjs",
12
+ "template.mjs",
13
+ "packagist.mjs",
14
+ "selfupdate.mjs",
15
+ "shell.mjs",
16
+ "project.mjs",
17
+ "template",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "prepack": "node build-template.mjs"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "keywords": [
27
+ "nevela",
28
+ "laravel",
29
+ "nextjs",
30
+ "scaffold",
31
+ "create"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/MarkColeMukisa/nevela.git",
36
+ "directory": "packages/create-nevela"
37
+ },
38
+ "homepage": "https://nevela-docs.vercel.app",
39
+ "license": "MIT",
40
+ "author": "Mark Cole MUKISA"
41
+ }
package/packagist.mjs CHANGED
@@ -32,3 +32,20 @@ export async function onPackagist(wanted) {
32
32
  return false;
33
33
  }
34
34
  }
35
+
36
+ /** The newest stable nevela/laravel on Packagist, as "x.y.z", or null when it can't be reached. */
37
+ export async function latestRelease() {
38
+ try {
39
+ const response = await fetch('https://repo.packagist.org/p2/nevela/laravel.json', { signal: AbortSignal.timeout(8000) });
40
+ if (!response.ok) return null;
41
+ const releases = (await response.json()).packages?.['nevela/laravel'] ?? [];
42
+ let latest = null;
43
+ for (const release of releases) {
44
+ const version = /^v?(\d+\.\d+\.\d+)$/.exec(release.version)?.[1];
45
+ if (version && (latest === null || isNewer(version, latest))) latest = version;
46
+ }
47
+ return latest;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }