create-nevela 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,21 +36,32 @@ php nevela resource Product --fields="name:string, sku:string!, price:money"
36
36
 
37
37
  That writes the Laravel code and the dashboard pages, and creates the table. `php nevela` lists everything else.
38
38
 
39
- ## Updating an app later
39
+ ## The nevela command
40
+
41
+ Install this package once and you have a `nevela` command, in every shell:
40
42
 
41
43
  ```sh
42
- php nevela update
44
+ npm install -g create-nevela
43
45
  ```
44
46
 
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.
47
+ ```sh
48
+ nevela new my-app # anywhere: create an app
49
+ nevela update # anywhere: update the nevela command itself
50
+ nevela dev # inside an app: run the API and the dashboard
51
+ nevela upgrade # inside an app: bring it to the latest Nevela
52
+ ```
46
53
 
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:
54
+ As in Grit, `update` is for the tool and `upgrade` is for an app.
55
+
56
+ ## Upgrading an app later
48
57
 
49
58
  ```sh
50
- npx create-nevela update
59
+ nevela upgrade
51
60
  ```
52
61
 
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/.
62
+ That updates the Laravel package, regenerates, and upgrades the dashboard files you have not changed. Files you changed are kept, and everything it replaces is backed up first.
63
+
64
+ Without the command installed, `npx create-nevela upgrade` does the same, in any shell and on an app of any age. So does the app's own launcher, `php nevela upgrade`. See https://nevela-docs.vercel.app/guides/updating/.
54
65
 
55
66
  ## Options
56
67
 
package/index.mjs CHANGED
@@ -12,10 +12,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 { latestRelease, onPackagist } from './packagist.mjs';
16
- import { COMMANDS, findProject, forward, update } from './project.mjs';
15
+ import { isNewer, latestRelease, onPackagist } from './packagist.mjs';
16
+ import { COMMANDS, findProject, forward, upgrade } from './project.mjs';
17
17
  import { shellLine, spawnCommand, windows } from './shell.mjs';
18
- import { newerVersion, runVersion } from './selfupdate.mjs';
18
+ import { latestVersion, newerVersion, runVersion } from './selfupdate.mjs';
19
+ import { globalInstall, INSTALL_COMMAND } from './tool.mjs';
19
20
  import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
20
21
 
21
22
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -41,12 +42,15 @@ const HELP = `
41
42
 
42
43
  Create a new Nevela app: a Laravel API and a Next.js dashboard.
43
44
 
44
- ${bold('Create an app')}
45
- pnpm create nevela <name> [options]
46
- npm create nevela@latest <name> -- [options]
45
+ ${bold('Get the nevela command')} (once; everything below is then this short)
46
+ npm install -g create-nevela
47
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
48
+ ${bold('Anywhere')}
49
+ nevela new <name> [options] Create an app. Without the command: pnpm create nevela <name>
50
+ nevela update Update the nevela command itself to the latest version
51
+
52
+ ${bold('Inside an app')}
53
+ nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
50
54
  nevela dev Run the API and the dashboard
51
55
  nevela status Check versions, migrations, users and the dashboard
52
56
  nevela resource <Name> --fields="…" [--seed]
@@ -301,7 +305,7 @@ Everything else runs from this folder too, with \`php nevela\`:
301
305
  \`\`\`sh
302
306
  php nevela user # add someone who can sign in
303
307
  php nevela status # versions, migrations, users, and what the dashboard is talking to
304
- php nevela update # update Nevela and the dashboard
308
+ php nevela upgrade # bring this app to the latest Nevela
305
309
  php nevela # everything it can do
306
310
  \`\`\`
307
311
 
@@ -328,25 +332,82 @@ async function inProject(name, args) {
328
332
  fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: pnpm create nevela my-app`);
329
333
  }
330
334
  if (name === 'version') console.log(`create-nevela ${VERSION}`);
331
- if (name !== 'update') process.exit(forward(project, name, args));
335
+ if (name !== 'upgrade') process.exit(forward(project, name, args));
332
336
 
333
- // Updating is the one command that must not run on stale code itself.
337
+ // Upgrading is the one command that must not run on stale code itself: it is the newest
338
+ // installer that knows what an older app needs repaired. So even a nevela command that
339
+ // hasn't been updated in a while upgrades an app correctly.
334
340
  if (!process.env.NEVELA_NO_SELF_UPDATE && !fromRepository) {
335
341
  const newer = await newerVersion(VERSION);
336
342
  if (newer) {
337
- const status = await runVersion(newer, ['update', ...args]);
343
+ const status = await runVersion(newer, ['upgrade', ...args]);
338
344
  if (status !== null) process.exit(status);
339
345
  }
340
346
  }
341
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
347
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
342
348
  const releases = await latestRelease();
343
- const status = await update(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
349
+ const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
344
350
  process.exit(status);
345
351
  }
346
352
 
353
+ /**
354
+ * `nevela update`, from anywhere: update the nevela command itself.
355
+ *
356
+ * As in Grit, updating the tool and upgrading an app are two commands. This one doesn't
357
+ * touch any app; it ends by saying how to upgrade the one you are in, if you are in one.
358
+ *
359
+ * It returns instead of calling process.exit(): on Windows, exiting in the same moment a
360
+ * fetch() has just finished trips an assertion inside Node and prints a crash.
361
+ */
362
+ async function updateTool(args) {
363
+ const project = findProject(process.cwd());
364
+ const install = fromRepository ? null : globalInstall(here);
365
+
366
+ if (!install) {
367
+ // Run through npx or `pnpm dlx`, which fetch the latest every time, started by an
368
+ // older nevela command that fetched this one to do its work, or a copy in a project's
369
+ // own node_modules. There is no installed command to update. Inside an app, "update" used to mean "upgrade this app", and
370
+ // instructions saying so are still around, so that is what it does.
371
+ if (project) {
372
+ console.log(`\n ${dim(`"update" now updates the nevela command, and an app is brought up to date with "upgrade". Upgrading this app.`)}`);
373
+ await inProject('upgrade', args);
374
+ }
375
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
376
+ console.log(` The nevela command isn't installed on this computer, so there is nothing to update.`);
377
+ console.log(` To install it: ${bold(INSTALL_COMMAND)}`);
378
+ console.log(` Then, anywhere: ${bold('nevela new my-app')} and inside an app: ${bold('nevela upgrade')}, ${bold('nevela dev')}\n`);
379
+ return;
380
+ }
381
+
382
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
383
+ const latest = await latestVersion();
384
+ const newer = latest !== null && isNewer(latest, VERSION) ? latest : null;
385
+ if (latest === null) {
386
+ // Not the same as being up to date, so it isn't reported as that.
387
+ console.log(` ${red('!')} Couldn't reach npm to see whether there is a newer nevela than ${bold(VERSION)}. Nothing was changed.`);
388
+ console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
389
+ process.exitCode = 1;
390
+ } else if (!newer) {
391
+ console.log(` ${green('✔')} nevela ${bold(VERSION)} is the latest.`);
392
+ } else {
393
+ console.log(` Updating the nevela command ${bold(VERSION)} → ${bold(newer)} ${dim(`(${install.command.join(' ')})`)}\n`);
394
+ const [command, ...rest] = install.command;
395
+ const result = spawnCommand(command, rest, { stdio: 'inherit' });
396
+ if (result.error || result.status !== 0) {
397
+ fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${install.command.join(' ')}`);
398
+ }
399
+ console.log(`\n ${green('✔')} nevela is now ${bold(newer)}.`);
400
+ }
401
+ if (project) {
402
+ console.log(` ${dim('This app is not changed by that. To bring it to the latest Nevela:')} ${bold('nevela upgrade')}`);
403
+ }
404
+ console.log('');
405
+ }
406
+
347
407
  async function main() {
348
408
  const argv = process.argv.slice(2);
349
409
  if (argv[0] === 'new') argv.shift();
410
+ else if (argv[0] === 'update') return updateTool(argv.slice(1));
350
411
  else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
351
412
  const options = parseArgs(argv);
352
413
 
@@ -399,7 +460,7 @@ async function main() {
399
460
  write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
400
461
  }
401
462
  // 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
463
+ // `nevela upgrade` compares against these to tell the files you changed from the ones
403
464
  // you didn't, so an update never overwrites your work.
404
465
  write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
405
466
  writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
@@ -510,7 +571,7 @@ async function main() {
510
571
  console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
511
572
  }
512
573
  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)}`);
574
+ console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
514
575
  if (nevela !== 'php nevela') {
515
576
  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.`)}`);
516
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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": {
@@ -14,6 +14,7 @@
14
14
  "selfupdate.mjs",
15
15
  "shell.mjs",
16
16
  "project.mjs",
17
+ "tool.mjs",
17
18
  "template",
18
19
  "LICENSE"
19
20
  ],
package/project.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // The same commands as the app's own `php nevela`, but started from Node. That matters in
4
4
  // shells where typing `php` doesn't work (Git Bash with Laravel Herd, where PHP is
5
- // php.bat), and for `update`, which has to work on an app of any age, including one from
5
+ // php.bat), and for `upgrade`, which has to work on an app of any age, including one from
6
6
  // before the app had a launcher at all.
7
7
 
8
8
  import fs from 'node:fs';
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['update', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -108,8 +108,13 @@ function inApp(project, command, args) {
108
108
  return result.status ?? 1;
109
109
  }
110
110
 
111
- /** `nevela update`: bring an app of any age to the latest Nevela. */
112
- export async function update(project, args, { latest, say }) {
111
+ /**
112
+ * `nevela upgrade`: bring an app of any age to the latest Nevela.
113
+ *
114
+ * The artisan command is called by its older name, nevela:update, which every version of
115
+ * the package answers to (from 0.3.0 it is an alias of nevela:upgrade).
116
+ */
117
+ export async function upgrade(project, args, { latest, say }) {
113
118
  const check = args.includes('--check');
114
119
  const manifestFile = path.join(project.api, 'composer.json');
115
120
  const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
package/selfupdate.mjs CHANGED
@@ -14,17 +14,27 @@ import { isNewer } from './packagist.mjs';
14
14
 
15
15
  const REGISTRY = 'https://registry.npmjs.org/create-nevela';
16
16
 
17
- /** The newest create-nevela on npm when it is newer than `current`, else null. Never throws. */
18
- export async function newerVersion(current) {
17
+ /** The newest create-nevela on npm, or null when npm couldn't be asked. Never throws. */
18
+ export async function latestVersion() {
19
19
  try {
20
20
  const response = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(4000) });
21
21
  const latest = (await response.json()).version;
22
- return isNewer(latest, current) ? latest : null;
22
+ return typeof latest === 'string' && /^\d+\.\d+\.\d+$/.test(latest) ? latest : null;
23
23
  } catch {
24
24
  return null;
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * The newest create-nevela on npm when it is newer than `current`, else null. Never throws.
30
+ * Null also when npm couldn't be asked: callers that only want to run the newest carry on
31
+ * with what they have.
32
+ */
33
+ export async function newerVersion(current) {
34
+ const latest = await latestVersion();
35
+ return latest !== null && isNewer(latest, current) ? latest : null;
36
+ }
37
+
28
38
  /**
29
39
  * Unpack a .tar (already gunzipped) into `dir`. Enough of the format for an npm tarball:
30
40
  * regular files and directories, with the long-name extensions npm uses.
@@ -43,7 +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
+ | `nevela:upgrade` | Bring the app to the latest Nevela: the package, the generated code and the dashboard. `nevela:update` is its older name and still works. |
47
47
 
48
48
  Full documentation: https://nevela-docs.vercel.app
49
49
 
@@ -30,7 +30,7 @@ final class StatusCommand extends Command
30
30
  $latest = Releases::latest();
31
31
  $row('Nevela', Nevela::VERSION.match (true) {
32
32
  $latest === null => ' <fg=gray>(couldn\'t check for a newer one)</>',
33
- version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela update)</>",
33
+ version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela upgrade)</>",
34
34
  default => ' <fg=green>(latest)</>',
35
35
  });
36
36
  $row('Laravel / PHP', app()->version().' / '.PHP_VERSION);
@@ -99,7 +99,7 @@ final class StatusCommand extends Command
99
99
  // What this app has made its own. An update leaves every one of these alone.
100
100
  $changes = $state->yourChanges($web);
101
101
  if ($changes === null) {
102
- $row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela update records them</>');
102
+ $row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela upgrade records them</>');
103
103
  } else {
104
104
  $count = count($changes['changed']) + count($changes['deleted']);
105
105
  $row('Your dashboard changes', $count === 0 ? 'none' : "{$count} file(s), kept on every update");
@@ -16,12 +16,18 @@ use Throwable;
16
16
 
17
17
  final class UpdateCommand extends Command
18
18
  {
19
- protected $signature = 'nevela:update
19
+ protected $signature = 'nevela:upgrade
20
20
  {--check : Show what would change, and change nothing}
21
- {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}
22
- {--undo : Put the dashboard back as it was before the last update}';
21
+ {--skip-package : Leave the nevela/laravel package as it is; upgrade only the dashboard}
22
+ {--undo : Put the dashboard back as it was before the last upgrade}';
23
23
 
24
- protected $description = 'Update Nevela: the package, the generated code and the dashboard';
24
+ /**
25
+ * The name this command had before 0.3.0, and still answers to. As in Grit, "update"
26
+ * is now what the nevela command does to itself, and "upgrade" is what it does to an app.
27
+ */
28
+ protected $aliases = ['nevela:update'];
29
+
30
+ protected $description = 'Bring this app to the latest Nevela: the package, the generated code and the dashboard';
25
31
 
26
32
  /** Apps created before the dashboard recorded its version all came from this template. */
27
33
  private const FIRST_TRACKED = '0.1.1';
@@ -184,7 +190,7 @@ final class UpdateCommand extends Command
184
190
  // The package's own files have just been replaced. Finish in a new process, so the
185
191
  // rest runs on the new code instead of a mix of old and new.
186
192
  $php = (new PhpExecutableFinder)->find(false) ?: 'php';
187
- $rest = new Process([$php, 'artisan', 'nevela:update', '--skip-package'], base_path(), null, null, null);
193
+ $rest = new Process([$php, 'artisan', 'nevela:upgrade', '--skip-package'], base_path(), null, null, null);
188
194
  $rest->run(fn ($type, $buffer) => $this->output->write($buffer));
189
195
 
190
196
  return $rest->getExitCode() ?? self::FAILURE;
@@ -312,7 +318,7 @@ final class UpdateCommand extends Command
312
318
  $state->write($web);
313
319
 
314
320
  if ($touched) {
315
- $this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela update --undo</>");
321
+ $this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela upgrade --undo</>");
316
322
  }
317
323
  }
318
324
  if ($done[DashboardUpdate::CONFLICT] !== []) {
@@ -225,7 +225,8 @@ final class ResourceGenerator
225
225
  \$name = array_shift(\$args) ?? 'help';
226
226
 
227
227
  // What you type => the artisan command it runs.
228
- \$nevela = ['resource', 'generate', 'seed', 'user', 'update', 'dev', 'status', 'version'];
228
+ // "update" is what upgrade was called before 0.3.0; it still works.
229
+ \$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version'];
229
230
  \$artisan = ['migrate', 'tinker', 'test', 'serve'];
230
231
  if (in_array(\$name, ['--version', '-v', '-V'], true)) {
231
232
  \$name = 'version';
@@ -254,7 +255,7 @@ final class ResourceGenerator
254
255
  php nevela generate regenerate after editing a descriptor
255
256
  php nevela seed Product fill a resource with records
256
257
  php nevela user create someone who can sign in
257
- php nevela update update Nevela and the dashboard
258
+ php nevela upgrade bring this app to the latest Nevela
258
259
  php nevela version which Nevela this is
259
260
 
260
261
  php nevela migrate php artisan migrate
@@ -18,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
18
18
  final class Nevela
19
19
  {
20
20
  /** Written by `pnpm release`; the same number as the repository's git tag. */
21
- public const VERSION = '0.2.1';
21
+ public const VERSION = '0.3.0';
22
22
 
23
23
  /** @var array<string, Descriptor> */
24
24
  private static array $descriptors = [];
@@ -148,16 +148,16 @@ final class Descriptor
148
148
  return str_replace('-', '_', Naming::snake($this->name));
149
149
  }
150
150
 
151
- /** Split on commas that are not inside enum parentheses. @return list<string> */
151
+ /** Split on commas that are not inside parentheses or square brackets. @return list<string> */
152
152
  private static function splitSpec(string $spec): array
153
153
  {
154
154
  $parts = [];
155
155
  $depth = 0;
156
156
  $current = '';
157
157
  foreach (str_split($spec) as $char) {
158
- if ($char === '(') {
158
+ if ($char === '(' || $char === '[') {
159
159
  $depth++;
160
- } elseif ($char === ')') {
160
+ } elseif ($char === ')' || $char === ']') {
161
161
  $depth--;
162
162
  }
163
163
  if ($char === ',' && $depth === 0) {
@@ -106,12 +106,15 @@ final class Field
106
106
  * category:belongsTo(Category) stored as categoryId
107
107
  * image:image an optimised image; image:image(product) names a profile
108
108
  * manual:file(pdf|document) any other upload
109
+ *
110
+ * Grit's and Flare's spellings are understood too: see normalise().
109
111
  */
110
112
  public static function parse(string $token): self
111
113
  {
112
- $token = trim($token);
114
+ $written = trim($token);
115
+ $token = self::normalise($written);
113
116
  if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
114
- throw new InvalidArgumentException("Invalid field \"{$token}\". Expected name:type, e.g. price:money or status:enum(draft|live)?");
117
+ throw new InvalidArgumentException("Invalid field \"{$written}\". Expected name:type, e.g. price:money, status:enum(draft|live)?, image:image or category:belongsTo(Category).");
115
118
  }
116
119
  [, $name, $type, $args, $suffix] = $m + [3 => '', 4 => ''];
117
120
  $name = Naming::camel($name);
@@ -142,6 +145,30 @@ final class Field
142
145
  return new self($name, $kind, $format, $required, $unique);
143
146
  }
144
147
 
148
+ /**
149
+ * The other ways people write a field, turned into Nevela's own.
150
+ *
151
+ * Grit puts what a type takes after a second colon, and Flare in square brackets, so
152
+ * someone coming from either types what they know:
153
+ *
154
+ * image:file:image image:file[image] image:file:[image] → image:file(image)
155
+ * category:belongs_to:Category → category:belongs_to(Category)
156
+ * status:enum:draft|live docs:file:[pdf, image] → …(draft|live), …(pdf|image)
157
+ */
158
+ private static function normalise(string $token): string
159
+ {
160
+ // Spaces around the punctuation don't mean anything: "file: [ image ]".
161
+ $token = (string) preg_replace('/\s*([:\[\]])\s*/', '$1', $token);
162
+ if (preg_match('/^([^:()\[\]]+):([A-Za-z_]+):?\[([^\[\]()]*)\]([?!]{0,2})$/', $token, $m)
163
+ || preg_match('/^([^:()\[\]]+):([A-Za-z_]+):([^:()\[\]?!]+)([?!]{0,2})$/', $token, $m)) {
164
+ $list = implode('|', array_filter(array_map('trim', preg_split('/[,|]/', $m[3])), fn ($value) => $value !== ''));
165
+
166
+ return "{$m[1]}:{$m[2]}({$list}){$m[4]}";
167
+ }
168
+
169
+ return $token;
170
+ }
171
+
145
172
  /** @param array<string, mixed> $data */
146
173
  public static function fromArray(string $name, array $data): self
147
174
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/template.mjs CHANGED
@@ -81,7 +81,7 @@ export function copyWebTemplate(to, { name, title }) {
81
81
 
82
82
  /**
83
83
  * A fingerprint of every template file, and the template's dependencies: what the app
84
- * records in .nevela.json so that `nevela update` can tell, later and exactly, which
84
+ * records in .nevela.json so that `nevela upgrade` can tell, later and exactly, which
85
85
  * files the developer changed. The same fingerprint the Laravel package computes: SHA-1
86
86
  * with line endings ignored.
87
87
  */
package/tool.mjs ADDED
@@ -0,0 +1,58 @@
1
+ // `nevela update`: the nevela command updating itself, from any folder.
2
+ //
3
+ // Two different things get newer, and they have two different commands, as in Grit:
4
+ //
5
+ // nevela update the `nevela` command on this computer
6
+ // nevela upgrade the app you are in: its Nevela package and its dashboard
7
+ //
8
+ // Updating the command means asking the package manager that installed it to install the
9
+ // latest. Which one that was is read from where this file is running from.
10
+
11
+ import fs from 'node:fs';
12
+
13
+ /** The command that installs the latest create-nevela globally, for each package manager. */
14
+ const INSTALL = {
15
+ npm: ['npm', 'install', '-g', 'create-nevela@latest'],
16
+ pnpm: ['pnpm', 'add', '-g', 'create-nevela@latest'],
17
+ yarn: ['yarn', 'global', 'add', 'create-nevela@latest'],
18
+ bun: ['bun', 'add', '-g', 'create-nevela@latest'],
19
+ };
20
+
21
+ /**
22
+ * How the running copy was installed, from the folder it is in.
23
+ *
24
+ * Returns the package manager and the command that updates it, or null when this copy
25
+ * isn't the installed command: it was fetched for a single run (npx, pnpm dlx, a
26
+ * self-update), it is the repository itself, or it is a dependency in some project's own
27
+ * node_modules. Updating the global command would not change the copy that is running in
28
+ * any of those cases.
29
+ *
30
+ * @param {string} dir The folder this package is running from
31
+ * @param {(file: string) => boolean} exists Whether a file exists; replaceable in tests
32
+ */
33
+ export function globalInstall(dir, exists = fs.existsSync) {
34
+ const plain = dir.replace(/\\/g, '/').replace(/\/+$/, '');
35
+ const where = plain.toLowerCase();
36
+
37
+ // Fetched for one run and thrown away afterwards.
38
+ if (/\/_npx\/|\/dlx[-/]|\/\.?bunx-|\/xfs-[0-9a-f]+\/|\/create-nevela-\d[^/]*\/package$/.test(where)) return null;
39
+ // Installed, as opposed to checked out: it sits in somebody's node_modules.
40
+ if (!/\/node_modules\/create-nevela$/.test(where)) return null;
41
+
42
+ // pnpm, yarn and bun each keep global packages in a folder of their own.
43
+ if (/\/pnpm\/global\//.test(where)) return { manager: 'pnpm', command: INSTALL.pnpm };
44
+ if (/\/\.bun\/install\/global\//.test(where)) return { manager: 'bun', command: INSTALL.bun };
45
+ if (/\/yarn\/(data\/)?global\/|\/\.config\/yarn\/global\//.test(where)) return { manager: 'yarn', command: INSTALL.yarn };
46
+
47
+ // npm's global folder is a plain node_modules, like a project's. Two things tell them
48
+ // apart: a project has a package.json beside its node_modules, and a dependency of a
49
+ // dependency (or pnpm's local store) sits in a node_modules inside another.
50
+ if (where.split('/node_modules/').length !== 2) return null;
51
+ const owner = plain.slice(0, where.lastIndexOf('/node_modules/'));
52
+ if (exists(`${owner}/package.json`)) return null;
53
+
54
+ return { manager: 'npm', command: INSTALL.npm };
55
+ }
56
+
57
+ /** What to type to get the `nevela` command, for someone who doesn't have it yet. */
58
+ export const INSTALL_COMMAND = INSTALL.npm.join(' ').replace('@latest', '');