create-nevela 0.1.0 → 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/README.md CHANGED
@@ -26,23 +26,34 @@ cd my-app
26
26
  pnpm run dev
27
27
  ```
28
28
 
29
- Open http://localhost:3000/sign-in. Add your first resource from `apps/api`:
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 the same folder:
30
32
 
31
33
  ```sh
32
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money"
33
- php artisan migrate
34
+ php nevela resource Product --fields="name:string, sku:string!, price:money"
34
35
  ```
35
36
 
37
+ That writes the Laravel code and the dashboard pages, and creates the table. `php nevela` lists everything else.
38
+
39
+ ## Updating an app later
40
+
41
+ ```sh
42
+ php nevela update
43
+ ```
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/.
46
+
36
47
  ## Options
37
48
 
38
49
  | Option | Meaning |
39
50
  |---|---|
40
51
  | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager for the dashboard. Default: the one you ran this with. |
41
52
  | `--no-install` | Don't install the dashboard's dependencies. |
42
- | `--no-user` | Don't ask to create the first user. |
53
+ | `--no-user` | Don't create the starter account. |
43
54
  | `--no-git` | Don't run `git init`. |
44
55
  | `--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. |
56
+ | `-y`, `--yes` | Ask nothing. Only the app name is ever asked for. |
46
57
 
47
58
  With npm, put options after `--`: `npm create nevela@latest my-app -- --no-install`.
48
59
 
package/index.mjs CHANGED
@@ -6,12 +6,13 @@
6
6
  //
7
7
  // No dependencies on purpose: this runs before anything is installed.
8
8
 
9
- import { spawnSync } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import fs from 'node:fs';
11
+ import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import readline from 'node:readline/promises';
13
14
  import { fileURLToPath } from 'node:url';
14
- import { onPackagist } from './packagist.mjs';
15
+ import { isNewer, onPackagist } from './packagist.mjs';
15
16
  import { copyLaravelPackage, copyWebTemplate } from './template.mjs';
16
17
 
17
18
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -19,6 +20,13 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf
19
20
  const DOCS = 'https://nevela-docs.vercel.app';
20
21
  const windows = process.platform === 'win32';
21
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
+
22
30
  const colour = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
23
31
  const bold = colour(1);
24
32
  const dim = colour(2);
@@ -38,11 +46,11 @@ const HELP = `
38
46
  ${bold('Options')}
39
47
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
40
48
  --no-install Don't install the dashboard's dependencies.
41
- --no-user Don't ask to create the first user.
49
+ --no-user Don't create the starter admin account.
42
50
  --no-git Don't run git init.
43
51
  --bundled-package Use the copy of nevela/laravel that ships with this installer
44
52
  instead of the release on Packagist.
45
- -y, --yes Ask nothing; take the defaults.
53
+ -y, --yes Ask nothing. (Only the app name is ever asked for.)
46
54
  -h, --help Show this.
47
55
  -v, --version Show the version.
48
56
 
@@ -75,10 +83,40 @@ function run(command, args, { cwd, interactive = false, allowFailure = false } =
75
83
  */
76
84
  function spawnCommand(command, args, options) {
77
85
  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 });
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 };
82
120
  }
83
121
 
84
122
  function available(command, args = ['--version']) {
@@ -86,13 +124,18 @@ function available(command, args = ['--version']) {
86
124
  return result.status === 0 ? `${result.stdout}${result.stderr}` : null;
87
125
  }
88
126
 
127
+ const duration = (seconds) => (seconds >= 60 ? `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s` : `${seconds.toFixed(1)}s`);
128
+
89
129
  async function step(label, work) {
90
130
  const started = Date.now();
91
- process.stdout.write(` ${dim('◇')} ${label}…`);
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}…`);
92
135
  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`);
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`);
96
139
  return result;
97
140
  }
98
141
 
@@ -123,6 +166,17 @@ function parseArgs(argv) {
123
166
  /** "my-shop" → "My Shop", for the dashboard's title. */
124
167
  const titleCase = (slug) => slug.split(/[-_]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
125
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
+
126
180
  function detectPackageManager(requested) {
127
181
  const known = ['pnpm', 'npm', 'yarn', 'bun'];
128
182
  if (requested) {
@@ -166,7 +220,7 @@ function write(file, contents) {
166
220
  fs.writeFileSync(file, contents);
167
221
  }
168
222
 
169
- function writeRootFiles(root, { name, title, pm }) {
223
+ function writeRootFiles(root, { name, title, pm, admin }) {
170
224
  write(path.join(root, 'package.json'), `${JSON.stringify({
171
225
  name,
172
226
  version: '0.1.0',
@@ -174,6 +228,7 @@ function writeRootFiles(root, { name, title, pm }) {
174
228
  type: 'module',
175
229
  scripts: {
176
230
  dev: 'node scripts/dev.mjs',
231
+ nevela: 'php nevela',
177
232
  'dev:api': 'cd apps/api && php artisan serve',
178
233
  'dev:web': `cd apps/web && ${pm} run dev`,
179
234
  },
@@ -236,23 +291,29 @@ ${pm} run dev
236
291
  \`\`\`
237
292
 
238
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:
239
296
 
240
- To create someone who can sign in:
297
+ | | |
298
+ |---|---|
299
+ | Email | \`${admin.email}\` |
300
+ | Password | \`${admin.password}\` |
301
+ ` : ''}
302
+ Everything else runs from this folder too, with \`php nevela\`:
241
303
 
242
304
  \`\`\`sh
243
- cd apps/api
244
- php artisan nevela:user
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
245
308
  \`\`\`
246
309
 
247
310
  ## Add a resource
248
311
 
249
312
  \`\`\`sh
250
- cd apps/api
251
- php artisan nevela:resource Product --fields="name:string, sku:string!, price:money, notes:text?"
252
- php artisan migrate
313
+ php nevela resource Product --fields="name:string, sku:string!, price:money, notes:text?"
253
314
  \`\`\`
254
315
 
255
- Reload the dashboard: Products is in the sidebar. Fill it with \`php artisan nevela:seed Product\`.
316
+ That creates the table as well. Reload the dashboard: Products is in the sidebar. Fill it with \`php nevela seed Product\`.
256
317
 
257
318
  ## Before real use
258
319
 
@@ -267,12 +328,21 @@ async function main() {
267
328
 
268
329
  console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
269
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
+
270
339
  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
340
  if (!name) {
274
- if (!prompt) fail('Give the app a name: pnpm create nevela my-app');
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 });
275
344
  name = (await prompt.question(' What is the app called? ')).trim();
345
+ prompt.close();
276
346
  }
277
347
  if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
278
348
  fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
@@ -286,78 +356,87 @@ async function main() {
286
356
  const title = titleCase(name);
287
357
  const api = path.join(root, 'apps', 'api');
288
358
  const web = path.join(root, 'apps', 'web');
359
+ const began = Date.now();
289
360
  fs.mkdirSync(path.join(root, 'apps'), { recursive: true });
290
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
+
291
376
  await step('Creating the Laravel app', () => {
292
377
  run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
293
378
  });
294
379
 
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 () => {
380
+ const tokens = await step('Installing Nevela and token sign-in', async () => {
301
381
  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.
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.
307
386
  copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
308
387
  run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
309
- run('composer', ['require', 'nevela/laravel:@dev', '--no-interaction', '--no-progress'], { cwd: api });
388
+ nevela = 'nevela/laravel:@dev';
310
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'));
311
395
  });
312
396
 
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 });
397
+ let admin = false;
398
+ await step('Setting up the database', () => {
321
399
  // Writes routes/nevela.php and the dashboard's (empty) resource registry.
322
400
  run('php', ['artisan', 'nevela:generate'], { cwd: api });
323
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
+ }
324
405
  });
325
406
 
326
- if (options.install) {
327
- await step(`Installing the dashboard's dependencies with ${pm}`, () => {
328
- run(pm, ['install'], { cwd: web });
329
- });
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;
330
411
  }
331
412
 
332
413
  if (options.git && available('git') && !run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, allowFailure: true }).ok) {
333
414
  run('git', ['init', '--quiet'], { cwd: root, allowFailure: true });
334
415
  }
335
416
 
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`);
417
+ console.log(`\n ${green('✔')} ${bold(`Created ${name}`)} ${dim(`in ${duration((Date.now() - began) / 1000)}`)}\n`);
349
418
  if (!tokens) {
350
419
  console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
351
420
  console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
352
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
+ }
353
425
  console.log(' Next:\n');
354
426
  console.log(` cd ${name}`);
355
427
  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')}`);
428
+ if (!admin) console.log(` php nevela user ${dim('# someone to sign in as')}`);
357
429
  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"')}`);
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')}`);
360
438
  console.log(` Docs: ${DOCS}/start/quickstart/\n`);
439
+ if (outdated) console.log(outdated);
361
440
  }
362
441
 
363
442
  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.2",
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": {
package/packagist.mjs CHANGED
@@ -1,25 +1,34 @@
1
- // Deciding where a new app gets nevela/laravel from.
2
-
3
- /** Whether `candidate` is a stable release Composer's `^major.minor` would accept. */
4
- export function satisfiesCaret(candidate, [major, minor]) {
5
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
6
- if (!match) return false;
7
- const [theirMajor, theirMinor] = match.slice(1).map(Number);
8
- // Below 1.0 a caret holds the minor version too: ^0.1 means 0.1.x only.
9
- return major === 0 ? theirMajor === 0 && theirMinor === minor : theirMajor === major && theirMinor >= minor;
10
- }
11
-
12
- /**
13
- * Whether Packagist has a release of nevela/laravel that goes with this installer. Being
14
- * listed isn't enough: with no tagged release yet, `composer require` would fail.
15
- */
16
- export async function onPackagist(wanted) {
17
- try {
18
- const response = await fetch('https://repo.packagist.org/p2/nevela/laravel.json', { signal: AbortSignal.timeout(8000) });
19
- if (!response.ok) return false;
20
- const releases = (await response.json()).packages?.['nevela/laravel'] ?? [];
21
- return releases.some((release) => satisfiesCaret(release.version, wanted));
22
- } catch {
23
- return false;
24
- }
25
- }
1
+ // Deciding where a new app gets nevela/laravel from.
2
+
3
+ /** Whether `candidate` is a stable release Composer's `^major.minor` would accept. */
4
+ export function satisfiesCaret(candidate, [major, minor]) {
5
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
6
+ if (!match) return false;
7
+ const [theirMajor, theirMinor] = match.slice(1).map(Number);
8
+ // Below 1.0 a caret holds the minor version too: ^0.1 means 0.1.x only.
9
+ return major === 0 ? theirMajor === 0 && theirMinor === minor : theirMajor === major && theirMinor >= minor;
10
+ }
11
+
12
+ /** Whether stable version `a` is newer than `b`. Anything that isn't x.y.z is never newer. */
13
+ export function isNewer(a, b) {
14
+ const parse = (version) => /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version))?.slice(1).map(Number);
15
+ const [left, right] = [parse(a), parse(b)];
16
+ if (!left || !right) return false;
17
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i];
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * Whether Packagist has a release of nevela/laravel that goes with this installer. Being
23
+ * listed isn't enough: with no tagged release yet, `composer require` would fail.
24
+ */
25
+ export async function onPackagist(wanted) {
26
+ try {
27
+ const response = await fetch('https://repo.packagist.org/p2/nevela/laravel.json', { signal: AbortSignal.timeout(8000) });
28
+ if (!response.ok) return false;
29
+ const releases = (await response.json()).packages?.['nevela/laravel'] ?? [];
30
+ return releases.some((release) => satisfiesCaret(release.version, wanted));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -43,6 +43,7 @@ Needs PHP 8.2 or newer and Laravel 11, 12 or 13.
43
43
  | `nevela:generate` | Regenerate after changing a descriptor. Your own code is kept. |
44
44
  | `nevela:seed` | Fill a resource with plausible records, and print how long it took. |
45
45
  | `nevela:user` | Create someone who can sign in. |
46
+ | `nevela:update` | Update the package, the generated code and the dashboard. |
46
47
 
47
48
  Full documentation: https://nevela-docs.vercel.app
48
49
 
@@ -13,6 +13,13 @@ return [
13
13
  */
14
14
  'web_path' => env('NEVELA_WEB_PATH', base_path('../web')),
15
15
 
16
+ /*
17
+ | The top of the project, where the `php nevela` launcher is written. Null works it
18
+ | out: two folders up when this app is at <project>/apps/<name>. Set a path if yours
19
+ | is somewhere else, or false to have no launcher.
20
+ */
21
+ 'root_path' => env('NEVELA_ROOT_PATH'),
22
+
16
23
  /* URL prefix and middleware for generated resource routes (routes/nevela.php). */
17
24
  'prefix' => 'api',
18
25
  'middleware' => ['api', 'auth:sanctum'],