create-nevela 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/banner.mjs +50 -0
- package/index.mjs +144 -27
- package/net.mjs +55 -0
- package/nevela.mjs +8 -0
- package/package.json +5 -2
- package/packagist.mjs +14 -21
- package/selfupdate.mjs +9 -12
- package/template/laravel/composer.json +2 -1
- package/template/laravel/config/nevela.php +66 -1
- package/template/laravel/database/migrations/2026_10_05_000000_create_nevela_auth_tables.php +66 -0
- package/template/laravel/src/Auth/Account.php +140 -0
- package/template/laravel/src/Auth/AuthMail.php +77 -0
- package/template/laravel/src/Auth/Challenges.php +135 -0
- package/template/laravel/src/Auth/Passkeys.php +206 -0
- package/template/laravel/src/Auth/Totp.php +106 -0
- package/template/laravel/src/Auth/Web.php +75 -0
- package/template/laravel/src/Console/GenerateCommand.php +1 -0
- package/template/laravel/src/Console/UpdateCommand.php +56 -4
- package/template/laravel/src/Generator/ResourceGenerator.php +60 -0
- package/template/laravel/src/Http/Auth/AccountController.php +252 -0
- package/template/laravel/src/Http/Auth/Answers.php +33 -0
- package/template/laravel/src/Http/Auth/SecurityController.php +188 -0
- package/template/laravel/src/Http/Auth/SignInController.php +302 -0
- package/template/laravel/src/Http/TokenController.php +19 -37
- package/template/laravel/src/Http/UploadController.php +24 -12
- package/template/laravel/src/Models/Passkey.php +38 -0
- package/template/laravel/src/Models/TwoFactor.php +64 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +72 -4
- package/template/web/app/api/[...path]/route.ts +5 -1
- package/template/web/app/api/auth/[...action]/route.ts +196 -0
- package/template/web/app/auth-actions.ts +4 -43
- package/template/web/app/dashboard/account/layout.tsx +29 -0
- package/template/web/app/dashboard/account/page.tsx +19 -0
- package/template/web/app/dashboard/account/password/page.tsx +10 -0
- package/template/web/app/dashboard/account/security/page.tsx +19 -0
- package/template/web/app/dashboard/account/sessions/page.tsx +9 -0
- package/template/web/app/dashboard/layout.tsx +2 -1
- package/template/web/app/forgot-password/page.tsx +22 -0
- package/template/web/app/reset-password/page.tsx +31 -0
- package/template/web/app/sign-in/page.tsx +34 -4
- package/template/web/app/sign-up/page.tsx +43 -0
- package/template/web/app/two-factor/page.tsx +34 -0
- package/template/web/app/verify-email/page.tsx +33 -0
- package/template/web/components/account/account-nav.tsx +39 -0
- package/template/web/components/account/sections.tsx +573 -0
- package/template/web/components/auth/otp-input.tsx +95 -0
- package/template/web/components/auth/password-field.tsx +116 -0
- package/template/web/components/auth/password-forms.tsx +76 -0
- package/template/web/components/auth/provider-icons.tsx +39 -0
- package/template/web/components/auth/sign-in-flow.tsx +307 -0
- package/template/web/components/auth/sign-up-form.tsx +93 -0
- package/template/web/components/auth/social-buttons.tsx +56 -0
- package/template/web/components/auth/two-factor-form.tsx +144 -0
- package/template/web/components/auth/use-resend.ts +44 -0
- package/template/web/components/auth/verify-email-form.tsx +147 -0
- package/template/web/components/dashboard/dashboard-sidebar.tsx +2 -2
- package/template/web/components/dashboard/user-menu.tsx +33 -1
- package/template/web/lib/auth-client.ts +279 -0
- package/template/web/lib/auth-config.ts +43 -0
- package/template/web/lib/laravel.ts +26 -1
- package/template/web/lib/password-rules.ts +167 -0
- package/template/web/lib/second-step.ts +35 -0
- package/template/web/lib/session.ts +12 -4
- package/template/web/package.json +3 -2
- package/tool.mjs +45 -0
- package/template/web/components/auth/sign-in-form.tsx +0 -50
package/README.md
CHANGED
|
@@ -45,8 +45,10 @@ npm install -g create-nevela
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
|
-
nevela
|
|
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
|
@@ -7,16 +7,18 @@
|
|
|
7
7
|
// No dependencies on purpose: this runs before anything is installed.
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import fs from 'node:fs';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import readline from 'node:readline/promises';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { banner } from './banner.mjs';
|
|
15
17
|
import { isNewer, latestRelease, onPackagist } from './packagist.mjs';
|
|
16
18
|
import { COMMANDS, findProject, forward, upgrade } from './project.mjs';
|
|
17
19
|
import { shellLine, spawnCommand, windows } from './shell.mjs';
|
|
18
20
|
import { latestVersion, newerVersion, runVersion } from './selfupdate.mjs';
|
|
19
|
-
import { globalInstall, INSTALL_COMMAND } from './tool.mjs';
|
|
21
|
+
import { copiesOnPath, globalInstall, INSTALL_COMMAND, removeCommand } from './tool.mjs';
|
|
20
22
|
import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
|
|
21
23
|
|
|
22
24
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -30,24 +32,39 @@ const DOCS = 'https://nevela-docs.vercel.app';
|
|
|
30
32
|
*/
|
|
31
33
|
const ADMIN = { name: 'Admin', email: 'admin@example.com', password: 'password' };
|
|
32
34
|
|
|
33
|
-
|
|
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);
|
|
34
38
|
const bold = colour(1);
|
|
35
39
|
const dim = colour(2);
|
|
36
40
|
const green = colour(32);
|
|
37
41
|
const red = colour(31);
|
|
38
|
-
|
|
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');
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
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';
|
|
42
52
|
|
|
43
|
-
|
|
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
|
+
}
|
|
57
|
+
|
|
58
|
+
const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
44
59
|
|
|
45
60
|
${bold('Get the nevela command')} (once; everything below is then this short)
|
|
46
61
|
npm install -g create-nevela
|
|
47
62
|
|
|
48
63
|
${bold('Anywhere')}
|
|
49
|
-
nevela new
|
|
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>
|
|
50
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
|
|
51
68
|
|
|
52
69
|
${bold('Inside an app')}
|
|
53
70
|
nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
|
|
@@ -175,6 +192,7 @@ function parseArgs(argv) {
|
|
|
175
192
|
for (let i = 0; i < argv.length; i++) {
|
|
176
193
|
const arg = argv[i];
|
|
177
194
|
if (arg === '-h' || arg === '--help') {
|
|
195
|
+
showTitle();
|
|
178
196
|
console.log(HELP);
|
|
179
197
|
process.exit(0);
|
|
180
198
|
} else if (arg === '-v' || arg === '--version') {
|
|
@@ -209,6 +227,8 @@ function phpIsTypeable() {
|
|
|
209
227
|
|
|
210
228
|
/** `nevela <command>` as someone should type it here: through PHP, or through the package manager. */
|
|
211
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';
|
|
212
232
|
if (phpIsTypeable()) return 'php nevela';
|
|
213
233
|
return { pnpm: 'pnpm nevela', yarn: 'yarn nevela', bun: 'bun run nevela' }[pm] ?? 'npm run nevela';
|
|
214
234
|
}
|
|
@@ -329,9 +349,8 @@ Docs: ${DOCS}
|
|
|
329
349
|
async function inProject(name, args) {
|
|
330
350
|
const project = findProject(process.cwd());
|
|
331
351
|
if (!project) {
|
|
332
|
-
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'}`);
|
|
333
353
|
}
|
|
334
|
-
if (name === 'version') console.log(`create-nevela ${VERSION}`);
|
|
335
354
|
if (name !== 'upgrade') process.exit(forward(project, name, args));
|
|
336
355
|
|
|
337
356
|
// Upgrading is the one command that must not run on stale code itself: it is the newest
|
|
@@ -350,6 +369,85 @@ async function inProject(name, args) {
|
|
|
350
369
|
process.exit(status);
|
|
351
370
|
}
|
|
352
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Say so when the nevela command is installed more than once. Only the first on the PATH
|
|
374
|
+
* ever runs, so an update to one of the others looks like an update that did nothing.
|
|
375
|
+
*/
|
|
376
|
+
function otherCopies() {
|
|
377
|
+
const copies = copiesOnPath();
|
|
378
|
+
if (copies.length < 2) return;
|
|
379
|
+
console.log(`\n ${red('!')} The nevela command is installed ${copies.length} times. Typing ${bold('nevela')} runs the first of these:`);
|
|
380
|
+
for (const dir of copies) console.log(` ${dir}`);
|
|
381
|
+
console.log(` Keep that one and remove the rest, so there is one to keep up to date:`);
|
|
382
|
+
for (const dir of copies.slice(1)) console.log(` ${bold(removeCommand(dir))}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The version of Nevela an app has installed, read from the package, or null. */
|
|
386
|
+
function appVersion(project) {
|
|
387
|
+
try {
|
|
388
|
+
const source = fs.readFileSync(path.join(project.api, 'vendor', 'nevela', 'laravel', 'src', 'Nevela.php'), 'utf8');
|
|
389
|
+
return /const VERSION = '([^']+)'/.exec(source)?.[1] ?? null;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** `nevela version`, from anywhere: the command's version, and the app's when in one. */
|
|
396
|
+
function showVersion() {
|
|
397
|
+
const project = findProject(process.cwd());
|
|
398
|
+
const install = fromRepository ? null : globalInstall(here);
|
|
399
|
+
console.log(`\n ${indigo(bold('Nevela command'))} ${bold(`v${VERSION}`)}${install ? ` ${dim(`installed with ${install.manager}`)}` : ''}`);
|
|
400
|
+
if (project) {
|
|
401
|
+
const app = appVersion(project);
|
|
402
|
+
if (app === null) {
|
|
403
|
+
console.log(` ${indigo(bold('This app'))} ${dim("Nevela isn't installed in it yet. Run: cd apps/api && composer install")}`);
|
|
404
|
+
} else {
|
|
405
|
+
const behind = isNewer(VERSION, app);
|
|
406
|
+
console.log(` ${indigo(bold('This app'))} ${bold(`v${app}`)}${behind ? ` ${dim('To bring it to the latest:')} ${bold('nevela upgrade')}` : ''}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
otherCopies();
|
|
410
|
+
console.log('');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Why `name` can't be the new app's name, or null when it can.
|
|
415
|
+
*/
|
|
416
|
+
function nameProblem(name) {
|
|
417
|
+
if (name === '') return 'Type a name, for example my-app.';
|
|
418
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
|
|
419
|
+
return `"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`;
|
|
420
|
+
}
|
|
421
|
+
const root = path.resolve(process.cwd(), name);
|
|
422
|
+
if (fs.existsSync(root) && fs.readdirSync(root).length > 0) return `${root} already exists and isn't empty.`;
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Ask what the app is called, until the answer is a name that can be used. The only
|
|
428
|
+
* question this ever asks, and only when the name was left off.
|
|
429
|
+
*/
|
|
430
|
+
async function askName() {
|
|
431
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
432
|
+
// Ctrl+C or the end of input, at the question: leave, having created nothing.
|
|
433
|
+
const closed = new Promise((resolve) => prompt.once('close', () => resolve(null)));
|
|
434
|
+
prompt.on('SIGINT', () => prompt.close());
|
|
435
|
+
try {
|
|
436
|
+
for (;;) {
|
|
437
|
+
const answer = await Promise.race([prompt.question(` ${green('What is the name of your app?')} `).catch(() => null), closed]);
|
|
438
|
+
if (answer === null) {
|
|
439
|
+
console.log('\n');
|
|
440
|
+
process.exit(130);
|
|
441
|
+
}
|
|
442
|
+
const problem = nameProblem(answer.trim());
|
|
443
|
+
if (problem === null) return answer.trim();
|
|
444
|
+
console.log(` ${red('✖')} ${problem}\n`);
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
prompt.close();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
353
451
|
/**
|
|
354
452
|
* `nevela update`, from anywhere: update the nevela command itself.
|
|
355
453
|
*
|
|
@@ -372,14 +470,16 @@ async function updateTool(args) {
|
|
|
372
470
|
console.log(`\n ${dim(`"update" now updates the nevela command, and an app is brought up to date with "upgrade". Upgrading this app.`)}`);
|
|
373
471
|
await inProject('upgrade', args);
|
|
374
472
|
}
|
|
375
|
-
|
|
473
|
+
showTitle();
|
|
376
474
|
console.log(` The nevela command isn't installed on this computer, so there is nothing to update.`);
|
|
377
475
|
console.log(` To install it: ${bold(INSTALL_COMMAND)}`);
|
|
378
476
|
console.log(` Then, anywhere: ${bold('nevela new my-app')} and inside an app: ${bold('nevela upgrade')}, ${bold('nevela dev')}\n`);
|
|
379
477
|
return;
|
|
380
478
|
}
|
|
381
479
|
|
|
382
|
-
|
|
480
|
+
showTitle();
|
|
481
|
+
console.log(` ${indigo(`Nevela self-update — current: v${VERSION}`)}\n`);
|
|
482
|
+
console.log(` ${dim('→ Checking npm for the latest release…')}\n`);
|
|
383
483
|
const latest = await latestVersion();
|
|
384
484
|
const newer = latest !== null && isNewer(latest, VERSION) ? latest : null;
|
|
385
485
|
if (latest === null) {
|
|
@@ -388,16 +488,17 @@ async function updateTool(args) {
|
|
|
388
488
|
console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
|
|
389
489
|
process.exitCode = 1;
|
|
390
490
|
} else if (!newer) {
|
|
391
|
-
console.log(` ${green(
|
|
491
|
+
console.log(` ${green(`✔ Already on the latest version (v${VERSION}). Nothing to do.`)}`);
|
|
392
492
|
} else {
|
|
393
|
-
console.log(` Updating
|
|
493
|
+
console.log(` Updating v${VERSION} → ${bold(`v${newer}`)} ${dim(`(${install.command.join(' ')})`)}\n`);
|
|
394
494
|
const [command, ...rest] = install.command;
|
|
395
495
|
const result = spawnCommand(command, rest, { stdio: 'inherit' });
|
|
396
496
|
if (result.error || result.status !== 0) {
|
|
397
497
|
fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${install.command.join(' ')}`);
|
|
398
498
|
}
|
|
399
|
-
console.log(`\n ${green(
|
|
499
|
+
console.log(`\n ${green(`✔ Updated to v${newer}.`)} ${dim('Every nevela command, and every app you create from now on, uses it.')}`);
|
|
400
500
|
}
|
|
501
|
+
otherCopies();
|
|
401
502
|
if (project) {
|
|
402
503
|
console.log(` ${dim('This app is not changed by that. To bring it to the latest Nevela:')} ${bold('nevela upgrade')}`);
|
|
403
504
|
}
|
|
@@ -408,10 +509,18 @@ async function main() {
|
|
|
408
509
|
const argv = process.argv.slice(2);
|
|
409
510
|
if (argv[0] === 'new') argv.shift();
|
|
410
511
|
else if (argv[0] === 'update') return updateTool(argv.slice(1));
|
|
512
|
+
else if (argv[0] === 'version') return showVersion();
|
|
411
513
|
else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
|
|
514
|
+
else if (argv[0] === 'help' || (startedAsNevela && argv.length === 0)) {
|
|
515
|
+
// `nevela` on its own says what it can do. `pnpm create nevela` on its own starts an
|
|
516
|
+
// app, and so does a newer installer that an older `nevela new` handed over to.
|
|
517
|
+
showTitle();
|
|
518
|
+
console.log(HELP);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
412
521
|
const options = parseArgs(argv);
|
|
413
522
|
|
|
414
|
-
|
|
523
|
+
showTitle();
|
|
415
524
|
|
|
416
525
|
// A package manager may hand over an older installer than the newest: pnpm skips versions
|
|
417
526
|
// published in the last few hours, and both pnpm and npm cache. Fetch the newest and let
|
|
@@ -429,19 +538,17 @@ async function main() {
|
|
|
429
538
|
}
|
|
430
539
|
|
|
431
540
|
let name = options.name;
|
|
432
|
-
if (
|
|
433
|
-
//
|
|
434
|
-
if (options.yes || !process.stdin.isTTY) fail(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
fail(`"${name}" can't be used as a name. Use lowercase letters, numbers and dashes, for example my-app.`);
|
|
541
|
+
if (name === undefined) {
|
|
542
|
+
// Nobody to ask: say what to type instead.
|
|
543
|
+
if (options.yes || !process.stdin.isTTY) fail(`Give the app a name: ${typedNevela ? 'nevela new my-app' : 'pnpm create nevela my-app'}`);
|
|
544
|
+
name = await askName();
|
|
545
|
+
console.log('');
|
|
546
|
+
} else {
|
|
547
|
+
const problem = nameProblem(name);
|
|
548
|
+
if (problem) fail(problem);
|
|
441
549
|
}
|
|
442
550
|
|
|
443
551
|
const root = path.resolve(process.cwd(), name);
|
|
444
|
-
if (fs.existsSync(root) && fs.readdirSync(root).length > 0) fail(`${root} already exists and isn't empty.`);
|
|
445
552
|
|
|
446
553
|
checkRequirements();
|
|
447
554
|
const pm = detectPackageManager(options.pm);
|
|
@@ -454,7 +561,14 @@ async function main() {
|
|
|
454
561
|
// The dashboard first, so its packages can download while Composer works on Laravel.
|
|
455
562
|
// The two don't touch each other, and Composer is the long part.
|
|
456
563
|
copyWebTemplate(web, { name, title });
|
|
457
|
-
|
|
564
|
+
// Shared by the dashboard and the API, so the API can believe what the dashboard tells it
|
|
565
|
+
// about a person's browser (for the list of devices signed in to an account). Made here,
|
|
566
|
+
// per app, and never the same twice.
|
|
567
|
+
const proxySecret = crypto.randomBytes(24).toString('hex');
|
|
568
|
+
write(
|
|
569
|
+
path.join(web, '.env.local'),
|
|
570
|
+
`# Where the Laravel API is, including its prefix.\nNEVELA_API_URL=http://127.0.0.1:8000/api\n\n# The same value as in apps/api/.env. It lets the API trust this app's word on a visitor's browser.\nNEVELA_PROXY_SECRET=${proxySecret}\n`,
|
|
571
|
+
);
|
|
458
572
|
if (pm === 'pnpm') {
|
|
459
573
|
// Its own workspace file: lets sharp build, and keeps pnpm from adopting a workspace further up.
|
|
460
574
|
write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
|
|
@@ -510,6 +624,9 @@ async function main() {
|
|
|
510
624
|
// artisan command is slow for the same reason the index was: files read for the first time.
|
|
511
625
|
const env = path.join(api, '.env');
|
|
512
626
|
if (!fs.existsSync(env) && fs.existsSync(`${env}.example`)) fs.copyFileSync(`${env}.example`, env);
|
|
627
|
+
if (fs.existsSync(env) && !/^NEVELA_PROXY_SECRET=/m.test(fs.readFileSync(env, 'utf8'))) {
|
|
628
|
+
fs.appendFileSync(env, `\n# The same value as in apps/web/.env.local.\nNEVELA_PROXY_SECRET=${proxySecret}\n`);
|
|
629
|
+
}
|
|
513
630
|
const sqlite = path.join(api, 'database', 'database.sqlite');
|
|
514
631
|
if (/^DB_CONNECTION=sqlite\s*$/m.test(fs.readFileSync(env, 'utf8')) && !fs.existsSync(sqlite)) fs.writeFileSync(sqlite, '');
|
|
515
632
|
return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
|
|
@@ -572,7 +689,7 @@ async function main() {
|
|
|
572
689
|
}
|
|
573
690
|
console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
574
691
|
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
|
575
|
-
if (nevela !== 'php nevela') {
|
|
692
|
+
if (nevela !== 'php nevela' && nevela !== 'nevela') {
|
|
576
693
|
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.`)}`);
|
|
577
694
|
}
|
|
578
695
|
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.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-nevela": "index.mjs",
|
|
8
|
-
"nevela": "
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
88
|
-
if (!response
|
|
89
|
-
untar(zlib.gunzipSync(
|
|
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
|
-
|
|
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 {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
31
31
|
"illuminate/database": "^11.17|^12.0|^13.0",
|
|
32
32
|
"illuminate/http": "^11.0|^12.0|^13.0",
|
|
33
|
-
"illuminate/console": "^11.0|^12.0|^13.0"
|
|
33
|
+
"illuminate/console": "^11.0|^12.0|^13.0",
|
|
34
|
+
"lbuchs/webauthn": "^2.2"
|
|
34
35
|
},
|
|
35
36
|
"require-dev": {
|
|
36
37
|
"phpunit/phpunit": "^11.0"
|
|
@@ -24,10 +24,75 @@ return [
|
|
|
24
24
|
'prefix' => 'api',
|
|
25
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
26
26
|
|
|
27
|
-
/*
|
|
27
|
+
/*
|
|
28
|
+
| Signing in. Every endpoint is under {prefix}/auth. Email and password is always
|
|
29
|
+
| available; the rest can be switched off here. A method that is off is refused by the
|
|
30
|
+
| API, not just hidden, and `php artisan nevela:generate` writes these choices to the
|
|
31
|
+
| dashboard (apps/web/lib/auth-config.ts) so its screens agree.
|
|
32
|
+
*/
|
|
28
33
|
'auth' => [
|
|
29
34
|
'enabled' => true,
|
|
30
35
|
'token_name' => 'nevela-web',
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
| Let anyone create their own account at /sign-up. Off by default, and accounts come
|
|
39
|
+
| from `php nevela user`: a generated policy lets every signed-in user do everything
|
|
40
|
+
| until you tighten it, so open sign-up on an untouched app would hand the dashboard
|
|
41
|
+
| to whoever finds it. Tighten app/Policies first, then set NEVELA_REGISTRATION=true.
|
|
42
|
+
*/
|
|
43
|
+
'registration' => (bool) env('NEVELA_REGISTRATION', false),
|
|
44
|
+
|
|
45
|
+
/* A sign-in link by email. */
|
|
46
|
+
'magic_link' => true,
|
|
47
|
+
|
|
48
|
+
/* A 6-digit sign-in code by email. */
|
|
49
|
+
'email_code' => true,
|
|
50
|
+
|
|
51
|
+
/* Face ID, Touch ID, Windows Hello or a security key. */
|
|
52
|
+
'passkeys' => true,
|
|
53
|
+
|
|
54
|
+
'two_factor' => [
|
|
55
|
+
/* Codes from an authenticator app (TOTP), with backup codes. */
|
|
56
|
+
'authenticator' => true,
|
|
57
|
+
/* Codes by email as the second step. */
|
|
58
|
+
'email' => true,
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
/* Refuse password sign-in until the email address is verified. */
|
|
62
|
+
'require_email_verification' => false,
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
| Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
66
|
+
| characters of the password's hash are sent, and an outage lets the password through.
|
|
67
|
+
*/
|
|
68
|
+
'check_breached_passwords' => true,
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
| Sign-in attempts are limited to ten a minute per account. This is the limit per
|
|
72
|
+
| address on top of that. It is high because the dashboard's server makes the calls:
|
|
73
|
+
| to Laravel, everyone using the dashboard comes from that one address.
|
|
74
|
+
*/
|
|
75
|
+
'attempts_per_address' => 300,
|
|
76
|
+
|
|
77
|
+
/* The role a self-registered account starts with. Null leaves the column alone. */
|
|
78
|
+
'default_role' => null,
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
| Where the dashboard is, for the links in emails and for passkeys, which are tied
|
|
82
|
+
| to its address. In development any localhost port is accepted as well, because
|
|
83
|
+
| the dashboard moves to another port when 3000 is taken.
|
|
84
|
+
*/
|
|
85
|
+
'web_url' => env('NEVELA_WEB_URL', 'http://localhost:3000'),
|
|
86
|
+
|
|
87
|
+
/*
|
|
88
|
+
| A secret the dashboard's server and Laravel share, so Laravel can believe what the
|
|
89
|
+
| dashboard says about a person's browser and address (for the list of devices).
|
|
90
|
+
| Put the same value in the dashboard's .env.local. New apps get one when created.
|
|
91
|
+
*/
|
|
92
|
+
'proxy_secret' => env('NEVELA_PROXY_SECRET'),
|
|
93
|
+
|
|
94
|
+
/* The name an authenticator app and a passkey prompt show. Null: the app's name. */
|
|
95
|
+
'issuer' => null,
|
|
31
96
|
],
|
|
32
97
|
|
|
33
98
|
/* List defaults, matching Flare's client. */
|