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 +10 -1
- package/index.mjs +184 -68
- package/package.json +41 -37
- package/packagist.mjs +17 -0
- package/project.mjs +126 -0
- package/selfupdate.mjs +94 -0
- package/shell.mjs +21 -0
- package/template/laravel/src/Console/SetupCommand.php +55 -0
- package/template/laravel/src/Console/StatusCommand.php +25 -2
- package/template/laravel/src/Console/UpdateCommand.php +160 -92
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +2 -1
- package/template/laravel/src/Support/DashboardState.php +118 -0
- package/template/laravel/src/Support/DashboardUpdate.php +57 -20
- package/template/laravel/src/Support/TemplateSource.php +144 -0
- package/template/web/package.json +1 -1
- package/template.mjs +127 -94
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.
|
|
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
|
|
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 {
|
|
16
|
-
import {
|
|
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('
|
|
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 +
|
|
156
|
+
const inPlace = process.stdout.isTTY && label.length + 30 < (process.stdout.columns ?? 80);
|
|
134
157
|
if (inPlace) process.stdout.write(` ${dim('◇')} ${label}…`);
|
|
135
|
-
|
|
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}
|
|
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
|
-
/**
|
|
167
|
-
|
|
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
|
-
/**
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
334
|
-
// to tell the files you changed from the ones
|
|
335
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
351
|
-
nevela = '
|
|
426
|
+
manifest.repositories = [...(manifest.repositories ?? []), { type: 'path', url: '../../packages/nevela-laravel' }];
|
|
427
|
+
nevela = '@dev';
|
|
352
428
|
}
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
392
|
-
console.log(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|