create-nevela 0.4.1 → 0.4.3

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
@@ -45,8 +45,10 @@ npm install -g create-nevela
45
45
  ```
46
46
 
47
47
  ```sh
48
- nevela new my-app # anywhere: create an app
48
+ nevela # anywhere: everything it can do
49
+ nevela new my-app # anywhere: create an app (leave the name off and it asks)
49
50
  nevela update # anywhere: update the nevela command itself
51
+ nevela version # anywhere: the command's version, and the app's when in one
50
52
  nevela dev # inside an app: run the API and the dashboard
51
53
  nevela upgrade # inside an app: bring it to the latest Nevela
52
54
  ```
package/banner.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // The name, large, at the top of the commands you run from anywhere: new, update, help.
2
+ //
3
+ // The same block letters as the Laravel installer and Grit, in Nevela's colours, one
4
+ // colour to a row so the name runs from blue through indigo to pink.
5
+
6
+ const LETTERS = {
7
+ N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
8
+ E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
9
+ V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
10
+ L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
11
+ A: [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
12
+ };
13
+
14
+ /** "NEVELA", six rows of the same width. */
15
+ export const ROWS = [0, 1, 2, 3, 4, 5].map((row) => [...'NEVELA'].map((letter) => LETTERS[letter][row]).join(''));
16
+ export const WIDTH = ROWS[0].length;
17
+
18
+ export const TAGLINE = 'Describe a resource. Get the API and the dashboard.';
19
+
20
+ /** A colour for each row, top to bottom, for what the terminal can show. */
21
+ const GRADIENT = {
22
+ // 16.7 million colours
23
+ 24: ['38;2;79;123;255', '38;2;99;102;241', '38;2;124;92;246', '38;2;168;85;247', '38;2;217;70;239', '38;2;244;114;182'],
24
+ // 256 colours
25
+ 8: ['38;5;69', '38;5;63', '38;5;99', '38;5;135', '38;5;171', '38;5;205'],
26
+ // 16 colours: blue above, magenta below
27
+ 4: ['94', '94', '94', '95', '95', '95'],
28
+ };
29
+
30
+ /**
31
+ * The banner, as text to print.
32
+ *
33
+ * @param {object} options
34
+ * @param {string} options.version
35
+ * @param {number} options.depth Bits of colour the terminal has: 24, 8, 4, 1 for none,
36
+ * and 0 when the output isn't a terminal at all.
37
+ * @param {number} [options.columns] How wide the terminal is
38
+ * @param {boolean} [options.compact] The name on one line, whatever the terminal can show
39
+ */
40
+ export function banner({ version, depth, columns = 80, compact = false }) {
41
+ const paint = (code, text) => (depth > 1 ? `\x1b[${code}m${text}\x1b[0m` : text);
42
+ // Into a file or a pipe, or a terminal too narrow for the letters: the name on one line.
43
+ if (compact || depth === 0 || columns < WIDTH + 4) {
44
+ // Indigo where there are 256 colours to pick it from; magenta where there are 16.
45
+ return `\n ${paint(depth >= 8 ? '1;38;5;99' : '1;95', 'Nevela')} ${paint(2, `v${version}`)}\n`;
46
+ }
47
+ const colours = GRADIENT[depth >= 24 ? 24 : depth >= 8 ? 8 : 4];
48
+ const rows = ROWS.map((row, index) => ` ${paint(colours[index], row)}`);
49
+ return `\n${rows.join('\n')}\n\n ${paint(2, `${TAGLINE} v${version}`)}\n`;
50
+ }
package/index.mjs CHANGED
@@ -6,18 +6,19 @@
6
6
  //
7
7
  // No dependencies on purpose: this runs before anything is installed.
8
8
 
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import crypto from 'node:crypto';
11
11
  import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import readline from 'node:readline/promises';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { banner } from './banner.mjs';
16
17
  import { isNewer, latestRelease, onPackagist } from './packagist.mjs';
17
18
  import { COMMANDS, findProject, forward, upgrade } from './project.mjs';
18
19
  import { shellLine, spawnCommand, windows } from './shell.mjs';
19
20
  import { latestVersion, newerVersion, runVersion } from './selfupdate.mjs';
20
- import { globalInstall, INSTALL_COMMAND } from './tool.mjs';
21
+ import { copiesOnPath, globalInstall, INSTALL_COMMAND, removeCommand } from './tool.mjs';
21
22
  import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
22
23
 
23
24
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -31,24 +32,39 @@ const DOCS = 'https://nevela-docs.vercel.app';
31
32
  */
32
33
  const ADMIN = { name: 'Admin', email: 'admin@example.com', password: 'password' };
33
34
 
34
- const colour = (code) => (text) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
35
+ /** Bits of colour the terminal has: 24, 8, 4, 1 for none, and 0 when this isn't going to a terminal. */
36
+ const depth = process.stdout.isTTY ? process.stdout.getColorDepth() : 0;
37
+ const colour = (code) => (text) => (depth > 1 ? `\x1b[${code}m${text}\x1b[0m` : text);
35
38
  const bold = colour(1);
36
39
  const dim = colour(2);
37
40
  const green = colour(32);
38
41
  const red = colour(31);
39
- const indigo = colour('38;5;99');
42
+ // Indigo where there are 256 colours to pick it from; magenta where there are 16.
43
+ const indigo = colour(depth >= 8 ? '38;5;99' : '95');
40
44
 
41
- const HELP = `
42
- ${bold('create-nevela')} ${dim(`v${VERSION}`)}
45
+ /**
46
+ * Whether `nevela` was typed, as opposed to create-nevela (`pnpm create nevela`). Kept in
47
+ * the environment so a newer installer this one hands over to knows it too.
48
+ */
49
+ const startedAsNevela = /^nevela(\.mjs)?$/.test(path.basename(process.argv[1] ?? ''));
50
+ const typedNevela = startedAsNevela || process.env.NEVELA_TYPED === 'nevela';
51
+ if (typedNevela) process.env.NEVELA_TYPED = 'nevela';
52
+
53
+ /** The name, large. Once per run: an installer that handed over to a newer one has shown it. */
54
+ function showTitle() {
55
+ console.log(banner({ version: VERSION, depth, columns: process.stdout.columns ?? 80, compact: Boolean(process.env.NEVELA_HANDED_OVER) }));
56
+ }
43
57
 
44
- Create a new Nevela app: a Laravel API and a Next.js dashboard.
58
+ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
45
59
 
46
60
  ${bold('Get the nevela command')} (once; everything below is then this short)
47
61
  npm install -g create-nevela
48
62
 
49
63
  ${bold('Anywhere')}
50
- nevela new <name> [options] Create an app. Without the command: pnpm create nevela <name>
64
+ nevela new [name] [options] Create an app. It asks for the name if you leave it off.
65
+ Without the command: pnpm create nevela <name>
51
66
  nevela update Update the nevela command itself to the latest version
67
+ nevela version Which version the nevela command is, and this app when in one
52
68
 
53
69
  ${bold('Inside an app')}
54
70
  nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
@@ -176,6 +192,7 @@ function parseArgs(argv) {
176
192
  for (let i = 0; i < argv.length; i++) {
177
193
  const arg = argv[i];
178
194
  if (arg === '-h' || arg === '--help') {
195
+ showTitle();
179
196
  console.log(HELP);
180
197
  process.exit(0);
181
198
  } else if (arg === '-v' || arg === '--version') {
@@ -210,6 +227,8 @@ function phpIsTypeable() {
210
227
 
211
228
  /** `nevela <command>` as someone should type it here: through PHP, or through the package manager. */
212
229
  function nevelaCommand(pm) {
230
+ // Typed as `nevela`, so the command is installed, and it is the shortest of the three.
231
+ if (typedNevela) return 'nevela';
213
232
  if (phpIsTypeable()) return 'php nevela';
214
233
  return { pnpm: 'pnpm nevela', yarn: 'yarn nevela', bun: 'bun run nevela' }[pm] ?? 'npm run nevela';
215
234
  }
@@ -330,9 +349,8 @@ Docs: ${DOCS}
330
349
  async function inProject(name, args) {
331
350
  const project = findProject(process.cwd());
332
351
  if (!project) {
333
- fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: pnpm create nevela my-app`);
352
+ fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: ${typedNevela ? 'nevela new my-app' : 'pnpm create nevela my-app'}`);
334
353
  }
335
- if (name === 'version') console.log(`create-nevela ${VERSION}`);
336
354
  if (name !== 'upgrade') process.exit(forward(project, name, args));
337
355
 
338
356
  // Upgrading is the one command that must not run on stale code itself: it is the newest
@@ -351,6 +369,130 @@ async function inProject(name, args) {
351
369
  process.exit(status);
352
370
  }
353
371
 
372
+ /**
373
+ * How the copy that is running was installed, or null when it isn't the installed command.
374
+ * The folder it was started through is passed as well as the one its files are in: under
375
+ * pnpm 11 only the first says it is pnpm's.
376
+ */
377
+ function installed() {
378
+ if (fromRepository) return null;
379
+ const startedFrom = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : here;
380
+ return globalInstall(here, fs.existsSync, startedFrom);
381
+ }
382
+
383
+ /** What typing `nevela` runs now, as "x.y.z", or null when that can't be found out. */
384
+ function versionOnPath() {
385
+ const answer = spawnCommand('nevela', ['--version'], { stdio: 'pipe', encoding: 'utf8' });
386
+ return /^\d+\.\d+\.\d+$/m.exec(`${answer.stdout ?? ''}`)?.[0] ?? null;
387
+ }
388
+
389
+ /** The launcher of the nevela command in `dir`: the file the shell runs. */
390
+ const launcherIn = (dir) => path.join(dir, windows ? 'nevela.cmd' : 'nevela');
391
+
392
+ /** The version of the nevela command in `dir`, as "x.y.z", or null when it won't say. */
393
+ function copyVersion(dir) {
394
+ const file = launcherIn(dir);
395
+ const answer = windows
396
+ ? spawnSync(`"${file}" --version`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
397
+ : spawnSync(file, ['--version'], { stdio: 'pipe', encoding: 'utf8' });
398
+ return /^\d+\.\d+\.\d+$/m.exec(`${answer.stdout ?? ''}`)?.[0] ?? null;
399
+ }
400
+
401
+ /**
402
+ * Say so when the nevela command is installed more than once. Only the first on the PATH
403
+ * ever runs, so an update to one of the others looks like an update that did nothing.
404
+ * The one to keep is the newest, which is not always the one that runs.
405
+ */
406
+ function otherCopies() {
407
+ const copies = copiesOnPath().map((dir) => ({ dir, version: copyVersion(dir) }));
408
+ if (copies.length < 2) return;
409
+ let keep = copies[0];
410
+ for (const copy of copies) {
411
+ if (copy.version && (!keep.version || isNewer(copy.version, keep.version))) keep = copy;
412
+ }
413
+ const width = Math.max(...copies.map((copy) => copy.dir.length));
414
+ console.log(`\n ${red('!')} The nevela command is installed ${copies.length} times. Typing ${bold('nevela')} runs the first of these:`);
415
+ for (const copy of copies) console.log(` ${copy.dir.padEnd(width)} ${copy.version ? `v${copy.version}` : ''}`);
416
+ console.log(keep === copies[0]
417
+ ? ` Keep that one and remove the rest, so there is one to keep up to date:`
418
+ : ` The one that runs is not the newest. Remove the others, and ${bold(`v${keep.version}`)} is what ${bold('nevela')} runs:`);
419
+ const launcher = (dir) => {
420
+ try {
421
+ return fs.readFileSync(launcherIn(dir), 'utf8').slice(0, 4000);
422
+ } catch {
423
+ return '';
424
+ }
425
+ };
426
+ const removals = new Set(copies.filter((copy) => copy !== keep).map((copy) => removeCommand(copy.dir, launcher(copy.dir))));
427
+ for (const removal of removals) console.log(` ${bold(removal)}`);
428
+ }
429
+
430
+ /** The version of Nevela an app has installed, read from the package, or null. */
431
+ function appVersion(project) {
432
+ try {
433
+ const source = fs.readFileSync(path.join(project.api, 'vendor', 'nevela', 'laravel', 'src', 'Nevela.php'), 'utf8');
434
+ return /const VERSION = '([^']+)'/.exec(source)?.[1] ?? null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ /** `nevela version`, from anywhere: the command's version, and the app's when in one. */
441
+ function showVersion() {
442
+ const project = findProject(process.cwd());
443
+ const install = installed();
444
+ console.log(`\n ${indigo(bold('Nevela command'))} ${bold(`v${VERSION}`)}${install ? ` ${dim(`installed with ${install.manager}`)}` : ''}`);
445
+ if (project) {
446
+ const app = appVersion(project);
447
+ if (app === null) {
448
+ console.log(` ${indigo(bold('This app'))} ${dim("Nevela isn't installed in it yet. Run: cd apps/api && composer install")}`);
449
+ } else {
450
+ const behind = isNewer(VERSION, app);
451
+ console.log(` ${indigo(bold('This app'))} ${bold(`v${app}`)}${behind ? ` ${dim('To bring it to the latest:')} ${bold('nevela upgrade')}` : ''}`);
452
+ }
453
+ }
454
+ otherCopies();
455
+ console.log('');
456
+ }
457
+
458
+ /**
459
+ * Why `name` can't be the new app's name, or null when it can.
460
+ */
461
+ function nameProblem(name) {
462
+ if (name === '') return 'Type a name, for example my-app.';
463
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
464
+ return `"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`;
465
+ }
466
+ const root = path.resolve(process.cwd(), name);
467
+ if (fs.existsSync(root) && fs.readdirSync(root).length > 0) return `${root} already exists and isn't empty.`;
468
+ return null;
469
+ }
470
+
471
+ /**
472
+ * Ask what the app is called, until the answer is a name that can be used. The only
473
+ * question this ever asks, and only when the name was left off.
474
+ */
475
+ async function askName() {
476
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
477
+ // Ctrl+C or the end of input, at the question: leave, having created nothing.
478
+ const closed = new Promise((resolve) => prompt.once('close', () => resolve(null)));
479
+ prompt.on('SIGINT', () => prompt.close());
480
+ try {
481
+ for (;;) {
482
+ const answer = await Promise.race([prompt.question(` ${green('What is the name of your app?')} `).catch(() => null), closed]);
483
+ if (answer === null) {
484
+ console.log('\n');
485
+ process.exit(130);
486
+ }
487
+ const problem = nameProblem(answer.trim());
488
+ if (problem === null) return answer.trim();
489
+ console.log(` ${red('✖')} ${problem}\n`);
490
+ }
491
+ } finally {
492
+ prompt.close();
493
+ }
494
+ }
495
+
354
496
  /**
355
497
  * `nevela update`, from anywhere: update the nevela command itself.
356
498
  *
@@ -362,7 +504,7 @@ async function inProject(name, args) {
362
504
  */
363
505
  async function updateTool(args) {
364
506
  const project = findProject(process.cwd());
365
- const install = fromRepository ? null : globalInstall(here);
507
+ const install = installed();
366
508
 
367
509
  if (!install) {
368
510
  // Run through npx or `pnpm dlx`, which fetch the latest every time, started by an
@@ -373,32 +515,46 @@ async function updateTool(args) {
373
515
  console.log(`\n ${dim(`"update" now updates the nevela command, and an app is brought up to date with "upgrade". Upgrading this app.`)}`);
374
516
  await inProject('upgrade', args);
375
517
  }
376
- console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
518
+ showTitle();
377
519
  console.log(` The nevela command isn't installed on this computer, so there is nothing to update.`);
378
520
  console.log(` To install it: ${bold(INSTALL_COMMAND)}`);
379
521
  console.log(` Then, anywhere: ${bold('nevela new my-app')} and inside an app: ${bold('nevela upgrade')}, ${bold('nevela dev')}\n`);
380
522
  return;
381
523
  }
382
524
 
383
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
525
+ showTitle();
526
+ console.log(` ${indigo(`Nevela self-update — current: v${VERSION}`)}\n`);
527
+ console.log(` ${dim('→ Checking npm for the latest release…')}\n`);
384
528
  const latest = await latestVersion();
385
529
  const newer = latest !== null && isNewer(latest, VERSION) ? latest : null;
386
530
  if (latest === null) {
387
531
  // Not the same as being up to date, so it isn't reported as that.
388
532
  console.log(` ${red('!')} Couldn't reach npm to see whether there is a newer nevela than ${bold(VERSION)}. Nothing was changed.`);
389
- console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
533
+ console.log(` Try again when you are online, or run: ${install.command().join(' ')}`);
390
534
  process.exitCode = 1;
391
535
  } else if (!newer) {
392
- console.log(` ${green('✔')} nevela ${bold(VERSION)} is the latest.`);
536
+ console.log(` ${green(`✔ Already on the latest version (v${VERSION}). Nothing to do.`)}`);
393
537
  } else {
394
- console.log(` Updating the nevela command ${bold(VERSION)} → ${bold(newer)} ${dim(`(${install.command.join(' ')})`)}\n`);
395
- const [command, ...rest] = install.command;
538
+ // The version by number, not "latest": pnpm holds "latest" back for a day.
539
+ const [command, ...rest] = install.command(newer);
540
+ console.log(` Updating v${VERSION} → ${bold(`v${newer}`)} ${dim(`(${[command, ...rest].join(' ')})`)}\n`);
396
541
  const result = spawnCommand(command, rest, { stdio: 'inherit' });
397
542
  if (result.error || result.status !== 0) {
398
- fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${install.command.join(' ')}`);
543
+ fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${[command, ...rest].join(' ')}`);
544
+ }
545
+ // The package manager saying so isn't the same as the command being newer: ask it.
546
+ const now = versionOnPath();
547
+ if (now === newer || now === null) {
548
+ console.log(`\n ${green(`✔ Updated to v${newer}.`)} ${dim('Every nevela command, and every app you create from now on, uses it.')}`);
549
+ } else {
550
+ console.log(`\n ${red('!')} ${install.manager} installed ${bold(`v${newer}`)}, but typing ${bold('nevela')} still runs ${bold(`v${now}`)}.`);
551
+ if (copiesOnPath().length < 2) {
552
+ console.log(` Open a new terminal and run ${bold('nevela version')}. If it still says v${now}, the copy that runs was installed another way.`);
553
+ }
554
+ process.exitCode = 1;
399
555
  }
400
- console.log(`\n ${green('✔')} nevela is now ${bold(newer)}.`);
401
556
  }
557
+ otherCopies();
402
558
  if (project) {
403
559
  console.log(` ${dim('This app is not changed by that. To bring it to the latest Nevela:')} ${bold('nevela upgrade')}`);
404
560
  }
@@ -409,10 +565,18 @@ async function main() {
409
565
  const argv = process.argv.slice(2);
410
566
  if (argv[0] === 'new') argv.shift();
411
567
  else if (argv[0] === 'update') return updateTool(argv.slice(1));
568
+ else if (argv[0] === 'version') return showVersion();
412
569
  else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
570
+ else if (argv[0] === 'help' || (startedAsNevela && argv.length === 0)) {
571
+ // `nevela` on its own says what it can do. `pnpm create nevela` on its own starts an
572
+ // app, and so does a newer installer that an older `nevela new` handed over to.
573
+ showTitle();
574
+ console.log(HELP);
575
+ return;
576
+ }
413
577
  const options = parseArgs(argv);
414
578
 
415
- console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
579
+ showTitle();
416
580
 
417
581
  // A package manager may hand over an older installer than the newest: pnpm skips versions
418
582
  // published in the last few hours, and both pnpm and npm cache. Fetch the newest and let
@@ -430,19 +594,17 @@ async function main() {
430
594
  }
431
595
 
432
596
  let name = options.name;
433
- if (!name) {
434
- // The only question this ever asks, and only when the name was left off.
435
- if (options.yes || !process.stdin.isTTY) fail('Give the app a name: pnpm create nevela my-app');
436
- const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
437
- name = (await prompt.question(' What is the app called? ')).trim();
438
- prompt.close();
439
- }
440
- if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
441
- fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
597
+ if (name === undefined) {
598
+ // Nobody to ask: say what to type instead.
599
+ if (options.yes || !process.stdin.isTTY) fail(`Give the app a name: ${typedNevela ? 'nevela new my-app' : 'pnpm create nevela my-app'}`);
600
+ name = await askName();
601
+ console.log('');
602
+ } else {
603
+ const problem = nameProblem(name);
604
+ if (problem) fail(problem);
442
605
  }
443
606
 
444
607
  const root = path.resolve(process.cwd(), name);
445
- if (fs.existsSync(root) && fs.readdirSync(root).length > 0) fail(`${root} already exists and isn't empty.`);
446
608
 
447
609
  checkRequirements();
448
610
  const pm = detectPackageManager(options.pm);
@@ -583,7 +745,7 @@ async function main() {
583
745
  }
584
746
  console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
585
747
  console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
586
- if (nevela !== 'php nevela') {
748
+ if (nevela !== 'php nevela' && nevela !== 'nevela') {
587
749
  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.`)}`);
588
750
  }
589
751
  console.log(` Docs: ${DOCS}/start/quickstart/\n`);
package/net.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // Reading a URL, without fetch().
2
+ //
3
+ // On Windows, a process that exits just after a fetch() has finished trips an assertion
4
+ // inside Node ("UV_HANDLE_CLOSING") and prints a crash where the error message should be.
5
+ // The installer asks npm for the latest version first thing, so every mistake it reports
6
+ // straight afterwards ended that way: no app name, a name it can't use, a folder that
7
+ // already exists. A plain https request on a connection that closes with the answer doesn't.
8
+
9
+ import https from 'node:https';
10
+
11
+ /**
12
+ * GET `url`. Resolves to { ok, status, body } with the body as a Buffer, or to null when
13
+ * the server couldn't be reached within `milliseconds`. Never rejects.
14
+ *
15
+ * The time allowed is for the whole exchange, redirects included, and not only for a
16
+ * silence on the line.
17
+ */
18
+ export function get(url, milliseconds, redirects = 3, deadline = Date.now() + milliseconds) {
19
+ return new Promise((resolve) => {
20
+ let timer = null;
21
+ const settle = (value) => {
22
+ clearTimeout(timer);
23
+ resolve(value);
24
+ };
25
+ try {
26
+ const request = https.get(url, { agent: false, headers: { connection: 'close', 'user-agent': 'create-nevela' } }, (response) => {
27
+ const status = response.statusCode ?? 0;
28
+ if (status >= 300 && status < 400 && response.headers.location && redirects > 0) {
29
+ response.resume();
30
+ settle(get(new URL(response.headers.location, url).href, milliseconds, redirects - 1, deadline));
31
+ return;
32
+ }
33
+ const chunks = [];
34
+ response.on('data', (chunk) => chunks.push(chunk));
35
+ response.on('end', () => settle({ ok: status >= 200 && status < 300, status, body: Buffer.concat(chunks) }));
36
+ response.on('error', () => settle(null));
37
+ });
38
+ request.on('error', () => settle(null));
39
+ timer = setTimeout(() => request.destroy(new Error('timed out')), Math.max(0, deadline - Date.now()));
40
+ } catch {
41
+ settle(null);
42
+ }
43
+ });
44
+ }
45
+
46
+ /** GET `url` and read it as JSON. Null when it couldn't be reached, or isn't JSON, or isn't a 2xx. */
47
+ export async function getJson(url, milliseconds) {
48
+ const response = await get(url, milliseconds);
49
+ if (!response?.ok) return null;
50
+ try {
51
+ return JSON.parse(response.body.toString('utf8'));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
package/nevela.mjs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // The `nevela` command.
3
+ //
4
+ // The same program as create-nevela. It has a file of its own so the program can tell
5
+ // which of the two was typed: `nevela` with nothing after it says what it can do, where
6
+ // `pnpm create nevela` with nothing after it starts a new app.
7
+
8
+ await import('./index.mjs');
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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": {
7
7
  "create-nevela": "index.mjs",
8
- "nevela": "index.mjs"
8
+ "nevela": "nevela.mjs"
9
9
  },
10
10
  "files": [
11
11
  "index.mjs",
12
+ "nevela.mjs",
13
+ "banner.mjs",
14
+ "net.mjs",
12
15
  "template.mjs",
13
16
  "packagist.mjs",
14
17
  "selfupdate.mjs",
package/packagist.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  // Deciding where a new app gets nevela/laravel from.
2
2
 
3
+ import { getJson } from './net.mjs';
4
+
5
+ const RELEASES = 'https://repo.packagist.org/p2/nevela/laravel.json';
6
+
3
7
  /** Whether `candidate` is a stable release Composer's `^major.minor` would accept. */
4
8
  export function satisfiesCaret(candidate, [major, minor]) {
5
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
9
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(candidate));
6
10
  if (!match) return false;
7
11
  const [theirMajor, theirMinor] = match.slice(1).map(Number);
8
12
  // Below 1.0 a caret holds the minor version too: ^0.1 means 0.1.x only.
@@ -23,29 +27,18 @@ export function isNewer(a, b) {
23
27
  * listed isn't enough: with no tagged release yet, `composer require` would fail.
24
28
  */
25
29
  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
- }
30
+ const releases = (await getJson(RELEASES, 8000))?.packages?.['nevela/laravel'];
31
+ return Array.isArray(releases) && releases.some((release) => satisfiesCaret(release?.version, wanted));
34
32
  }
35
33
 
36
34
  /** The newest stable nevela/laravel on Packagist, as "x.y.z", or null when it can't be reached. */
37
35
  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;
36
+ const releases = (await getJson(RELEASES, 8000))?.packages?.['nevela/laravel'];
37
+ if (!Array.isArray(releases)) return null;
38
+ let latest = null;
39
+ for (const release of releases) {
40
+ const version = /^v?(\d+\.\d+\.\d+)$/.exec(String(release?.version))?.[1];
41
+ if (version && (latest === null || isNewer(version, latest))) latest = version;
50
42
  }
43
+ return latest;
51
44
  }
package/selfupdate.mjs CHANGED
@@ -10,19 +10,15 @@ import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import zlib from 'node:zlib';
13
+ import { get, getJson } from './net.mjs';
13
14
  import { isNewer } from './packagist.mjs';
14
15
 
15
16
  const REGISTRY = 'https://registry.npmjs.org/create-nevela';
16
17
 
17
18
  /** The newest create-nevela on npm, or null when npm couldn't be asked. Never throws. */
18
19
  export async function latestVersion() {
19
- try {
20
- const response = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(4000) });
21
- const latest = (await response.json()).version;
22
- return typeof latest === 'string' && /^\d+\.\d+\.\d+$/.test(latest) ? latest : null;
23
- } catch {
24
- return null;
25
- }
20
+ const latest = (await getJson(`${REGISTRY}/latest`, 4000))?.version;
21
+ return typeof latest === 'string' && /^\d+\.\d+\.\d+$/.test(latest) ? latest : null;
26
22
  }
27
23
 
28
24
  /**
@@ -84,16 +80,17 @@ export function untar(buffer, dir) {
84
80
  export async function runVersion(version, args) {
85
81
  const dir = path.join(os.tmpdir(), `create-nevela-${version}-${process.pid}`);
86
82
  try {
87
- const response = await fetch(`${REGISTRY}/-/create-nevela-${version}.tgz`, { signal: AbortSignal.timeout(30000) });
88
- if (!response.ok) return null;
89
- untar(zlib.gunzipSync(Buffer.from(await response.arrayBuffer())), dir);
83
+ const response = await get(`${REGISTRY}/-/create-nevela-${version}.tgz`, 30000);
84
+ if (!response?.ok) return null;
85
+ untar(zlib.gunzipSync(response.body), dir);
90
86
  const entry = path.join(dir, 'package', 'index.mjs');
91
87
  if (!fs.existsSync(entry)) return null;
92
88
 
93
89
  const result = spawnSync(process.execPath, [entry, ...args], {
94
90
  stdio: 'inherit',
95
- // The newer one must not go looking for a newer one in turn.
96
- env: { ...process.env, NEVELA_NO_SELF_UPDATE: '1' },
91
+ // The newer one must not go looking for a newer one in turn, and the name has
92
+ // already been printed large once.
93
+ env: { ...process.env, NEVELA_NO_SELF_UPDATE: '1', NEVELA_HANDED_OVER: '1' },
97
94
  });
98
95
  return result.status ?? 1;
99
96
  } catch {
@@ -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.4.1';
21
+ public const VERSION = '0.4.3';
22
22
 
23
23
  /** @var array<string, Descriptor> */
24
24
  private static array $descriptors = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/tool.mjs CHANGED
@@ -6,43 +6,64 @@
6
6
  // nevela upgrade the app you are in: its Nevela package and its dashboard
7
7
  //
8
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.
9
+ // newest version. Which one that was is read from where this file is running from.
10
10
 
11
11
  import fs from 'node:fs';
12
12
 
13
- /** The command that installs the latest create-nevela globally, for each package manager. */
13
+ /**
14
+ * The command that installs create-nevela globally, for each package manager.
15
+ *
16
+ * `nevela update` names the exact version it found on npm, not "latest": pnpm holds a
17
+ * version back for a day after it is published, so `pnpm add -g create-nevela@latest`
18
+ * installs yesterday's. Asked for a version by number, it installs that one.
19
+ */
14
20
  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'],
21
+ npm: (version) => ['npm', 'install', '-g', `create-nevela@${version}`],
22
+ pnpm: (version) => ['pnpm', 'add', '-g', `create-nevela@${version}`],
23
+ yarn: (version) => ['yarn', 'global', 'add', `create-nevela@${version}`],
24
+ bun: (version) => ['bun', 'add', '-g', `create-nevela@${version}`],
19
25
  };
20
26
 
21
27
  /**
22
- * How the running copy was installed, from the folder it is in.
28
+ * How the running copy was installed, from the folder it is in and the folder it was
29
+ * started through.
30
+ *
31
+ * Those two differ under pnpm 11, which keeps the files of every package in its store and
32
+ * leaves a link to them in its global folder. Node reports the store; the command was
33
+ * started through the global folder. Going by the store alone, a pnpm install looked like
34
+ * an npm one, and `nevela update` updated a copy that was not the one running.
23
35
  *
24
- * Returns the package manager and the command that updates it, or null when this copy
36
+ * Returns the package manager and `command(version)`, which updates it, or null when this copy
25
37
  * isn't the installed command: it was fetched for a single run (npx, pnpm dlx, a
26
38
  * self-update), it is the repository itself, or it is a dependency in some project's own
27
39
  * node_modules. Updating the global command would not change the copy that is running in
28
40
  * any of those cases.
29
41
  *
30
- * @param {string} dir The folder this package is running from
42
+ * @param {string} dir The folder this package's files are in
31
43
  * @param {(file: string) => boolean} exists Whether a file exists; replaceable in tests
44
+ * @param {string} startedFrom The folder it was started through, when that is another
32
45
  */
33
- export function globalInstall(dir, exists = fs.existsSync) {
34
- const plain = dir.replace(/\\/g, '/').replace(/\/+$/, '');
46
+ export function globalInstall(dir, exists = fs.existsSync, startedFrom = dir) {
47
+ const tidy = (folder) => folder.replace(/\\/g, '/').replace(/\/+$/, '');
48
+ const plain = tidy(dir);
35
49
  const where = plain.toLowerCase();
50
+ const started = tidy(startedFrom).toLowerCase();
51
+ const found = (manager) => ({ manager, command: (version = 'latest') => INSTALL[manager](version) });
36
52
 
37
53
  // 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;
54
+ if ([where, started].some((folder) => /\/_npx\/|\/dlx[-/]|\/\.?bunx-|\/xfs-[0-9a-f]+\/|\/create-nevela-\d[^/]*\/package$/.test(folder))) return null;
39
55
  // Installed, as opposed to checked out: it sits in somebody's node_modules.
40
56
  if (!/\/node_modules\/create-nevela$/.test(where)) return null;
41
57
 
42
58
  // 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 };
59
+ for (const folder of [started, where]) {
60
+ if (!/\/node_modules\/create-nevela$/.test(folder)) continue;
61
+ if (/\/pnpm\/global\/|\/global\/v?\d+\/([^/]+\/)?node_modules\/create-nevela$/.test(folder)) return found('pnpm');
62
+ if (/\/\.bun\/install\/global\//.test(folder)) return found('bun');
63
+ if (/\/yarn\/(data\/)?global\/|\/\.config\/yarn\/global\//.test(folder)) return found('yarn');
64
+ }
65
+ // In pnpm's store, and not reached through its global folder: a project's dependency.
66
+ if (/\/store\/v?\d+\/links\//.test(where)) return null;
46
67
 
47
68
  // npm's global folder is a plain node_modules, like a project's. Two things tell them
48
69
  // apart: a project has a package.json beside its node_modules, and a dependency of a
@@ -51,8 +72,57 @@ export function globalInstall(dir, exists = fs.existsSync) {
51
72
  const owner = plain.slice(0, where.lastIndexOf('/node_modules/'));
52
73
  if (exists(`${owner}/package.json`)) return null;
53
74
 
54
- return { manager: 'npm', command: INSTALL.npm };
75
+ return found('npm');
76
+ }
77
+
78
+ /**
79
+ * Every folder on the PATH that holds a nevela command, in the order the shell looks.
80
+ *
81
+ * More than one means it was installed more than once, say with npm and with pnpm. The
82
+ * shell runs the first and never mentions the rest, so updating one of the others changes
83
+ * nothing you can see: `npm install -g create-nevela@latest` succeeds and `nevela` is
84
+ * still the old one.
85
+ *
86
+ * @param {Record<string, string | undefined>} env
87
+ * @param {object} [system] Replaceable in tests
88
+ */
89
+ export function copiesOnPath(env = process.env, { exists = fs.existsSync, real = fs.realpathSync, windows = process.platform === 'win32' } = {}) {
90
+ const names = windows ? ['nevela.cmd', 'nevela.exe', 'nevela.ps1', 'nevela'] : ['nevela'];
91
+ const separator = windows ? '\\' : '/';
92
+ const found = [];
93
+ const seen = new Set();
94
+ for (const entry of String(env.PATH ?? env.Path ?? '').split(windows ? ';' : ':')) {
95
+ const dir = entry.trim().replace(/^"|"$/g, '').replace(/[\\/]+$/, '');
96
+ if (!dir) continue;
97
+ const file = names.map((name) => `${dir}${separator}${name}`).find((candidate) => exists(candidate));
98
+ if (!file) continue;
99
+ // The same folder listed twice, or two names for one folder (/bin and /usr/bin), is one copy.
100
+ let target = file;
101
+ try {
102
+ target = real(file);
103
+ } catch {
104
+ // Left as it is.
105
+ }
106
+ const key = windows ? target.toLowerCase() : target;
107
+ if (seen.has(key)) continue;
108
+ seen.add(key);
109
+ found.push(dir);
110
+ }
111
+ return found;
112
+ }
113
+
114
+ /**
115
+ * What removes the copy whose command is in `dir`, judged by the folder and by the
116
+ * launcher in it: a small script that names the folder the package itself is in.
117
+ */
118
+ export function removeCommand(dir, launcher = '') {
119
+ const where = dir.replace(/\\/g, '/').toLowerCase();
120
+ const points = launcher.replace(/\\/g, '/').toLowerCase();
121
+ if (/\/pnpm(\/|$)/.test(where) || /\/global\/v?\d+\/([^/]+\/)?node_modules\/create-nevela\//.test(points)) return 'pnpm remove -g create-nevela';
122
+ if (/\/\.bun\//.test(where)) return 'bun remove -g create-nevela';
123
+ if (/\/yarn\/|\/\.yarn\//.test(where)) return 'yarn global remove create-nevela';
124
+ return 'npm uninstall -g create-nevela';
55
125
  }
56
126
 
57
127
  /** 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', '');
128
+ export const INSTALL_COMMAND = 'npm install -g create-nevela';