create-nevela 0.1.3 → 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
@@ -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) {
@@ -286,18 +321,51 @@ Docs: ${DOCS}
286
321
  `);
287
322
  }
288
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
+
289
347
  async function main() {
290
- 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);
291
352
 
292
353
  console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
293
354
 
294
- // A package manager may serve an older installer than the newest one: pnpm holds back
295
- // versions published in the last day, and both pnpm and npm cache. Say so, with the fix.
296
- const newer = await newerVersion();
297
- const outdated = newer
298
- ? ` ${red('!')} create-nevela ${bold(newer)} is out, and this is ${VERSION}. To use it: ${bold(`pnpm create nevela@${newer} ${options.name ?? 'my-app'}`)}\n`
299
- : null;
300
- 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
+ }
301
369
 
302
370
  let name = options.name;
303
371
  if (!name) {
@@ -330,41 +398,82 @@ async function main() {
330
398
  // Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
331
399
  write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
332
400
  }
333
- // Which dashboard this app started from: `php artisan nevela:update` compares against it
334
- // to tell the files you changed from the ones you didn't.
335
- 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`);
336
405
  writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
337
406
  const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
338
407
 
339
- await step('Creating the Laravel app', () => {
340
- run('composer', ['create-project', 'laravel/laravel', 'apps/api', '--no-interaction', '--no-progress', '--prefer-dist'], { cwd: root });
341
- });
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 });
342
411
 
343
- const tokens = await step('Installing Nevela and token sign-in', async () => {
344
412
  const wanted = VERSION.split('.').slice(0, 2).map(Number);
345
- 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 };
346
422
  if (options.bundledPackage || !(await onPackagist(wanted))) {
347
423
  // No matching release on Packagist (or --bundled-package): the copy that travels with
348
424
  // this installer is placed in the app and installed by path.
349
425
  copyLaravelPackage(path.join(root, 'packages', 'nevela-laravel'));
350
- run('composer', ['config', 'repositories.nevela', 'path', '../../packages/nevela-laravel'], { cwd: api });
351
- nevela = 'nevela/laravel:@dev';
426
+ manifest.repositories = [...(manifest.repositories ?? []), { type: 'path', url: '../../packages/nevela-laravel' }];
427
+ nevela = '@dev';
352
428
  }
353
- // One Composer run for both. `php artisan install:api` would add Sanctum in a run of
354
- // its own, plus a routes/api.php this app doesn't use: Nevela registers its routes itself.
355
- run('composer', ['require', 'laravel/sanctum', nevela, '--no-interaction', '--no-progress'], { cwd: api });
356
- 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, '');
357
454
  return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
358
455
  });
359
456
 
360
457
  let admin = false;
361
458
  await step('Setting up the database', () => {
362
- // 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 });
363
474
  run('php', ['artisan', 'nevela:generate'], { cwd: api });
364
475
  run('php', ['artisan', 'migrate', '--force', '--no-interaction'], { cwd: api });
365
- if (options.user && tokens) {
366
- admin = run('php', ['artisan', 'nevela:user', `--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`], { cwd: api, allowFailure: true }).ok;
367
- }
476
+ if (account.length) admin = run('php', ['artisan', 'nevela:user', ...account], { cwd: api, allowFailure: true }).ok;
368
477
  });
369
478
 
370
479
  let installed = false;
@@ -382,22 +491,29 @@ async function main() {
382
491
  console.log(` ${red('!')} I couldn't add Sanctum's trait to apps/api/app/Models/User.php. Add it by hand:`);
383
492
  console.log(` ${dim('use Laravel\\Sanctum\\HasApiTokens; and use HasApiTokens; inside the class')}\n`);
384
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
+ }
385
497
  if (packages && !installed) {
386
498
  console.log(` ${red('!')} The dashboard's packages didn't install. Run it yourself to see why: cd ${name}/apps/web && ${pm} install\n`);
387
499
  }
388
500
  console.log(' Next:\n');
389
501
  console.log(` cd ${name}`);
390
502
  if (!options.install) console.log(` cd apps/web && ${pm} install && cd ../..`);
391
- if (!admin) console.log(` php nevela user ${dim('# someone to sign in as')}`);
392
- console.log(' php nevela 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`);
393
506
  console.log(` Then open ${indigo('http://localhost:3000/sign-in')}${admin ? ' and sign in with:' : ''}`);
394
507
  if (admin) {
395
508
  console.log(`\n Email ${bold(ADMIN.email)}`);
396
509
  console.log(` Password ${bold(ADMIN.password)}\n`);
397
- 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.`)}`);
398
516
  }
399
- console.log(`\n Add your first resource: ${dim('php nevela resource Product --fields="name:string, price:money"')}`);
400
- console.log(` Check the app any time: ${dim('php nevela status')} Everything else: ${dim('php nevela')}`);
401
517
  console.log(` Docs: ${DOCS}/start/quickstart/\n`);
402
518
  if (outdated) console.log(outdated);
403
519
  }
package/package.json CHANGED
@@ -1,37 +1,41 @@
1
- {
2
- "name": "create-nevela",
3
- "version": "0.1.3",
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
+ }
package/project.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // `nevela <command>` inside an existing app.
2
+ //
3
+ // The same commands as the app's own `php nevela`, but started from Node. That matters in
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
6
+ // before the app had a launcher at all.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { spawnCommand } from './shell.mjs';
11
+
12
+ /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
13
+ const NEVELA = ['update', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
+ /** These go to the artisan command of the same name. */
15
+ const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
16
+
17
+ export const COMMANDS = [...NEVELA, ...ARTISAN, 'artisan'];
18
+
19
+ /** The app this folder belongs to: the nearest parent holding apps/api/artisan. */
20
+ export function findProject(from) {
21
+ let dir = path.resolve(from);
22
+ for (;;) {
23
+ if (fs.existsSync(path.join(dir, 'apps', 'api', 'artisan'))) {
24
+ return { root: dir, api: path.join(dir, 'apps', 'api'), web: path.join(dir, 'apps', 'web') };
25
+ }
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+
32
+ /** The artisan arguments for `nevela <name> …args`. */
33
+ export function artisanArguments(name, args) {
34
+ if (name === 'artisan') return args;
35
+ if (ARTISAN.includes(name)) return [name, ...args];
36
+ // A new resource is no use until its table exists, so make it in the same step.
37
+ const rest = args.filter((arg) => arg !== '--no-migrate');
38
+ if (name === 'resource' && !args.includes('--no-migrate')) rest.push('--migrate');
39
+ return [`nevela:${name}`, ...rest];
40
+ }
41
+
42
+ /**
43
+ * What stops Composer from updating nevela/laravel, repaired in composer.json.
44
+ * Returns a line describing each repair, for the user to see.
45
+ */
46
+ export function repairManifest(manifest, latest) {
47
+ const repairs = [];
48
+ const current = manifest.require?.['nevela/laravel'];
49
+ const caret = latest ? `^${latest.split('.').slice(0, 2).join('.')}` : null;
50
+
51
+ // Installed from a folder in the app, from before the package was on Packagist.
52
+ const repositories = Array.isArray(manifest.repositories) ? manifest.repositories : Object.values(manifest.repositories ?? {});
53
+ const bundled = repositories.filter((repo) => repo?.type === 'path' && /nevela-laravel\/?$/.test(String(repo.url).replace(/\\/g, '/')));
54
+ if (bundled.length && caret) {
55
+ const kept = repositories.filter((repo) => !bundled.includes(repo));
56
+ if (kept.length) manifest.repositories = kept;
57
+ else delete manifest.repositories;
58
+ manifest.require['nevela/laravel'] = caret;
59
+ repairs.push(`nevela/laravel now comes from Packagist (${caret}) instead of the packages/nevela-laravel folder, which you can delete`);
60
+ return repairs;
61
+ }
62
+
63
+ // Windows installs from create-nevela 0.1.0 dropped the caret: "0.1" pins one exact version.
64
+ if (typeof current === 'string' && /^\d+\.\d+(\.\d+)?$/.test(current)) {
65
+ manifest.require['nevela/laravel'] = caret ?? `^${current.split('.').slice(0, 2).join('.')}`;
66
+ repairs.push(`"nevela/laravel": "${current}" pinned one exact version; it is now "${manifest.require['nevela/laravel']}"`);
67
+ }
68
+ return repairs;
69
+ }
70
+
71
+ /** Make sure `pnpm nevela …` and a port-safe `dev` exist in the app's root package.json. */
72
+ export function repairScripts(root) {
73
+ const file = path.join(root, 'package.json');
74
+ if (!fs.existsSync(file)) return [];
75
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
76
+ const repairs = [];
77
+ manifest.scripts ??= {};
78
+ if (!manifest.scripts.nevela) {
79
+ manifest.scripts.nevela = 'php nevela';
80
+ repairs.push('added the "nevela" script: pnpm nevela <command>');
81
+ }
82
+ if (manifest.scripts.dev === 'node scripts/dev.mjs') {
83
+ manifest.scripts.dev = 'php nevela dev';
84
+ repairs.push('"dev" now runs php nevela dev, which picks a free port');
85
+ }
86
+ if (repairs.length) fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
87
+ return repairs;
88
+ }
89
+
90
+ /** Run a command in the app's Laravel folder, attached to this terminal. Returns its exit code. */
91
+ function inApp(project, command, args) {
92
+ const result = spawnCommand(command, args, { cwd: project.api, stdio: 'inherit' });
93
+ if (result.error) {
94
+ console.error(`\n Couldn't run ${command}: ${result.error.message}\n`);
95
+ return 1;
96
+ }
97
+ return result.status ?? 1;
98
+ }
99
+
100
+ /** `nevela update`: bring an app of any age to the latest Nevela. */
101
+ export async function update(project, args, { latest, say }) {
102
+ const check = args.includes('--check');
103
+ const manifestFile = path.join(project.api, 'composer.json');
104
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
105
+ const before = JSON.stringify(manifest);
106
+ const repairs = repairManifest(manifest, latest);
107
+
108
+ if (!check) {
109
+ if (JSON.stringify(manifest) !== before) fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 4)}\n`);
110
+ repairs.push(...repairScripts(project.root));
111
+ for (const repair of repairs) say(repair);
112
+
113
+ // Composer first, from here: an app from before 0.1.2 has no update command of its own yet.
114
+ const status = inApp(project, 'composer', ['update', 'nevela/laravel', '--no-interaction', '--no-progress']);
115
+ if (status !== 0) return status;
116
+ return inApp(project, 'php', ['artisan', 'nevela:update', '--skip-package', ...args]);
117
+ }
118
+
119
+ for (const repair of repairs) say(`would fix: ${repair}`);
120
+ return inApp(project, 'php', ['artisan', 'nevela:update', ...args]);
121
+ }
122
+
123
+ /** Any other command: hand it to artisan in the app. */
124
+ export function forward(project, name, args) {
125
+ return inApp(project, 'php', ['artisan', ...artisanArguments(name, args)]);
126
+ }