create-nevela 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/index.mjs +12 -49
- package/package.json +1 -1
- package/template/laravel/src/Console/DevCommand.php +104 -0
- package/template/laravel/src/Console/MakeResourceCommand.php +11 -3
- package/template/laravel/src/Console/StatusCommand.php +172 -0
- package/template/laravel/src/Console/UpdateCommand.php +24 -25
- package/template/laravel/src/Console/VersionCommand.php +20 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -8
- package/template/laravel/src/Nevela.php +161 -151
- package/template/laravel/src/NevelaServiceProvider.php +14 -1
- package/template/laravel/src/Support/Releases.php +45 -0
- package/template/web/app/auth-actions.ts +12 -4
- package/template/web/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,14 +16,14 @@ You need PHP 8.3 or newer, Composer, and Node.js 20 or newer.
|
|
|
16
16
|
my-app/
|
|
17
17
|
├─ apps/api/ a Laravel app with Sanctum and Nevela installed
|
|
18
18
|
├─ apps/web/ the Next.js dashboard, pointed at the API
|
|
19
|
-
└─
|
|
19
|
+
└─ nevela every command, from this folder: php nevela
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Then:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
cd my-app
|
|
26
|
-
|
|
26
|
+
php nevela dev
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Open http://localhost:3000/sign-in and sign in as `admin@example.com` with the password `password`. That starter account is in your local database only; add your own with `php artisan nevela:user`.
|
package/index.mjs
CHANGED
|
@@ -227,7 +227,7 @@ function writeRootFiles(root, { name, title, pm, admin }) {
|
|
|
227
227
|
private: true,
|
|
228
228
|
type: 'module',
|
|
229
229
|
scripts: {
|
|
230
|
-
dev: '
|
|
230
|
+
dev: 'php nevela dev',
|
|
231
231
|
nevela: 'php nevela',
|
|
232
232
|
'dev:api': 'cd apps/api && php artisan serve',
|
|
233
233
|
'dev:web': `cd apps/web && ${pm} run dev`,
|
|
@@ -236,50 +236,6 @@ function writeRootFiles(root, { name, title, pm, admin }) {
|
|
|
236
236
|
|
|
237
237
|
write(path.join(root, '.gitignore'), 'node_modules/\nvendor/\n.env\n.env.*\n!.env.example\n.next/\n*.tsbuildinfo\nnext-env.d.ts\n');
|
|
238
238
|
|
|
239
|
-
write(path.join(root, 'scripts', 'dev.mjs'), `// Run the Laravel API and the Next.js dashboard together: \`${pm} run dev\`.
|
|
240
|
-
import { spawn } from 'node:child_process';
|
|
241
|
-
|
|
242
|
-
const shell = process.platform === 'win32';
|
|
243
|
-
const apps = [
|
|
244
|
-
{ name: 'api', colour: 35, command: 'php', args: ['artisan', 'serve'], cwd: 'apps/api' },
|
|
245
|
-
{ name: 'web', colour: 36, command: '${pm}', args: ['run', 'dev'], cwd: 'apps/web' },
|
|
246
|
-
];
|
|
247
|
-
|
|
248
|
-
const running = apps.map(({ name, colour, command, args, cwd }) => {
|
|
249
|
-
// One string when going through a shell: Node warns about (and doesn't escape) separate args there.
|
|
250
|
-
const child = shell
|
|
251
|
-
? spawn([command, ...args].join(' '), { cwd, shell, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
252
|
-
: spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
253
|
-
const prefix = \`\\x1b[\${colour}m\${name}\\x1b[0m │ \`;
|
|
254
|
-
for (const stream of [child.stdout, child.stderr]) {
|
|
255
|
-
let rest = '';
|
|
256
|
-
stream.on('data', (chunk) => {
|
|
257
|
-
const lines = (rest + chunk).split('\\n');
|
|
258
|
-
rest = lines.pop();
|
|
259
|
-
for (const line of lines) console.log(prefix + line);
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
child.on('exit', (code) => {
|
|
263
|
-
console.log(\`\${prefix}stopped\${code ? \` (exit \${code})\` : ''}\`);
|
|
264
|
-
stop();
|
|
265
|
-
});
|
|
266
|
-
return child;
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
let stopping = false;
|
|
270
|
-
function stop() {
|
|
271
|
-
if (stopping) return;
|
|
272
|
-
stopping = true;
|
|
273
|
-
for (const child of running) {
|
|
274
|
-
// On Windows the child is a shell; killing it alone would leave php and node running.
|
|
275
|
-
if (shell && child.pid) spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
276
|
-
else child.kill();
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
process.on('SIGINT', stop);
|
|
280
|
-
process.on('SIGTERM', stop);
|
|
281
|
-
`);
|
|
282
|
-
|
|
283
239
|
write(path.join(root, 'README.md'), `# ${title}
|
|
284
240
|
|
|
285
241
|
A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard in \`apps/web\`.
|
|
@@ -287,10 +243,16 @@ A [Nevela](${DOCS}) app: a Laravel API in \`apps/api\` and a Next.js dashboard i
|
|
|
287
243
|
## Run it
|
|
288
244
|
|
|
289
245
|
\`\`\`sh
|
|
290
|
-
|
|
246
|
+
php nevela dev
|
|
291
247
|
\`\`\`
|
|
292
248
|
|
|
293
|
-
That starts Laravel
|
|
249
|
+
That starts the Laravel API and the dashboard together and prints where each is: normally http://127.0.0.1:8000 and http://localhost:3000. If another program already has a port, it uses the next free one. Sign in at http://localhost:3000/sign-in.
|
|
250
|
+
|
|
251
|
+
To check the app at any time (versions, migrations, users, and whether the dashboard is reaching this API):
|
|
252
|
+
|
|
253
|
+
\`\`\`sh
|
|
254
|
+
php nevela status
|
|
255
|
+
\`\`\`
|
|
294
256
|
${admin ? `
|
|
295
257
|
The app starts with one account, in your local database only:
|
|
296
258
|
|
|
@@ -303,6 +265,7 @@ Everything else runs from this folder too, with \`php nevela\`:
|
|
|
303
265
|
|
|
304
266
|
\`\`\`sh
|
|
305
267
|
php nevela user # add someone who can sign in
|
|
268
|
+
php nevela status # versions, migrations, users, and what the dashboard is talking to
|
|
306
269
|
php nevela update # update Nevela and the dashboard
|
|
307
270
|
php nevela # everything it can do
|
|
308
271
|
\`\`\`
|
|
@@ -426,7 +389,7 @@ async function main() {
|
|
|
426
389
|
console.log(` cd ${name}`);
|
|
427
390
|
if (!options.install) console.log(` cd apps/web && ${pm} install && cd ../..`);
|
|
428
391
|
if (!admin) console.log(` php nevela user ${dim('# someone to sign in as')}`);
|
|
429
|
-
console.log(
|
|
392
|
+
console.log(' php nevela dev\n');
|
|
430
393
|
console.log(` Then open ${indigo('http://localhost:3000/sign-in')}${admin ? ' and sign in with:' : ''}`);
|
|
431
394
|
if (admin) {
|
|
432
395
|
console.log(`\n Email ${bold(ADMIN.email)}`);
|
|
@@ -434,7 +397,7 @@ async function main() {
|
|
|
434
397
|
console.log(` ${dim('That account is in this app\'s local database only. Add your own: php nevela user')}`);
|
|
435
398
|
}
|
|
436
399
|
console.log(`\n Add your first resource: ${dim('php nevela resource Product --fields="name:string, price:money"')}`);
|
|
437
|
-
console.log(`
|
|
400
|
+
console.log(` Check the app any time: ${dim('php nevela status')} Everything else: ${dim('php nevela')}`);
|
|
438
401
|
console.log(` Docs: ${DOCS}/start/quickstart/\n`);
|
|
439
402
|
if (outdated) console.log(outdated);
|
|
440
403
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Symfony\Component\Process\PhpExecutableFinder;
|
|
7
|
+
use Symfony\Component\Process\Process;
|
|
8
|
+
|
|
9
|
+
final class DevCommand extends Command
|
|
10
|
+
{
|
|
11
|
+
protected $signature = 'nevela:dev
|
|
12
|
+
{--port=8000 : The first port to try for the API}
|
|
13
|
+
{--api-only : Start the API without the dashboard}';
|
|
14
|
+
|
|
15
|
+
protected $description = 'Run the API and the dashboard together';
|
|
16
|
+
|
|
17
|
+
public function handle(): int
|
|
18
|
+
{
|
|
19
|
+
$wanted = max(1, (int) $this->option('port'));
|
|
20
|
+
$port = self::freePort($wanted);
|
|
21
|
+
if ($port === null) {
|
|
22
|
+
$this->components->error("Ports {$wanted} to ".($wanted + 19).' are all in use. Free one, or pass --port.');
|
|
23
|
+
|
|
24
|
+
return self::FAILURE;
|
|
25
|
+
}
|
|
26
|
+
if ($port !== $wanted) {
|
|
27
|
+
// Without this check the dashboard would talk to whatever already owns the port.
|
|
28
|
+
// On Windows two servers can listen on the same one, and the older one answers.
|
|
29
|
+
$this->components->warn("Port {$wanted} is already in use by another program, so the API is on {$port}.");
|
|
30
|
+
}
|
|
31
|
+
$url = "http://127.0.0.1:{$port}";
|
|
32
|
+
|
|
33
|
+
$php = (new PhpExecutableFinder)->find(false) ?: 'php';
|
|
34
|
+
$api = new Process([$php, 'artisan', 'serve', '--host=127.0.0.1', "--port={$port}"], base_path(), null, null, null);
|
|
35
|
+
|
|
36
|
+
$web = null;
|
|
37
|
+
$webPath = config('nevela.web_path');
|
|
38
|
+
if (! $this->option('api-only') && $webPath && is_dir($webPath)) {
|
|
39
|
+
// The dashboard is told where the API really is, whatever its .env.local says.
|
|
40
|
+
$web = Process::fromShellCommandline(self::packageManager($webPath).' run dev', $webPath, ['NEVELA_API_URL' => "{$url}/api"], null, null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$this->components->info('Starting '.($web ? 'the API and the dashboard' : 'the API').'. Press Ctrl+C to stop.');
|
|
44
|
+
$this->components->twoColumnDetail('API', $url);
|
|
45
|
+
if ($web) {
|
|
46
|
+
$this->components->twoColumnDetail('Dashboard', 'http://localhost:3000/sign-in (see below if that port is taken)');
|
|
47
|
+
}
|
|
48
|
+
$this->newLine();
|
|
49
|
+
|
|
50
|
+
$api->start($this->printer('api', 35));
|
|
51
|
+
$web?->start($this->printer('web', 36));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
while ($api->isRunning() && ($web === null || $web->isRunning())) {
|
|
55
|
+
usleep(200_000);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
// Whichever stopped, or Ctrl+C: don't leave the other one running behind.
|
|
59
|
+
$web?->stop(3);
|
|
60
|
+
$api->stop(3);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return self::SUCCESS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The first port from `$from` that nothing is listening on, or null if twenty in a row are taken. */
|
|
67
|
+
public static function freePort(int $from): ?int
|
|
68
|
+
{
|
|
69
|
+
for ($port = $from; $port < $from + 20; $port++) {
|
|
70
|
+
$socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.3);
|
|
71
|
+
if ($socket === false) {
|
|
72
|
+
return $port;
|
|
73
|
+
}
|
|
74
|
+
fclose($socket);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The package manager a folder was installed with, from its lock file. */
|
|
81
|
+
public static function packageManager(string $dir): string
|
|
82
|
+
{
|
|
83
|
+
return match (true) {
|
|
84
|
+
is_file("{$dir}/pnpm-lock.yaml"), is_file("{$dir}/pnpm-workspace.yaml") => 'pnpm',
|
|
85
|
+
is_file("{$dir}/yarn.lock") => 'yarn',
|
|
86
|
+
is_file("{$dir}/bun.lockb"), is_file("{$dir}/bun.lock") => 'bun',
|
|
87
|
+
default => 'npm',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Prints a process's output a line at a time, each line labelled with which app it came from. */
|
|
92
|
+
private function printer(string $name, int $colour): \Closure
|
|
93
|
+
{
|
|
94
|
+
$rest = '';
|
|
95
|
+
|
|
96
|
+
return function (string $type, string $chunk) use ($name, $colour, &$rest) {
|
|
97
|
+
$lines = explode("\n", $rest.str_replace("\r\n", "\n", $chunk));
|
|
98
|
+
$rest = array_pop($lines);
|
|
99
|
+
foreach ($lines as $line) {
|
|
100
|
+
$this->output->writeln("\e[{$colour}m{$name}\e[0m │ ".$line, \Symfony\Component\Console\Output\OutputInterface::OUTPUT_RAW);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -16,7 +16,8 @@ final class MakeResourceCommand extends Command
|
|
|
16
16
|
{--icon= : Lucide icon name for the dashboard, e.g. package}
|
|
17
17
|
{--group= : Sidebar group in the dashboard}
|
|
18
18
|
{--force : Overwrite an existing descriptor}
|
|
19
|
-
{--migrate : Run the new migration straight away}
|
|
19
|
+
{--migrate : Run the new migration straight away}
|
|
20
|
+
{--seed= : Fill the new resource with records too (25, or the number you give). Runs the migration first}';
|
|
20
21
|
|
|
21
22
|
protected $description = 'Describe a resource once, then generate its Laravel API and Flare dashboard descriptor';
|
|
22
23
|
|
|
@@ -51,12 +52,19 @@ final class MakeResourceCommand extends Command
|
|
|
51
52
|
Nevela::forget();
|
|
52
53
|
$status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
// "--seed" on its own and "--seed=100" both count; without a number it is 25.
|
|
56
|
+
$seed = $this->input->hasParameterOption('--seed') || $this->option('seed') !== null;
|
|
57
|
+
$migrate = $this->option('migrate') || $seed;
|
|
58
|
+
|
|
59
|
+
if ($status === self::SUCCESS && $migrate) {
|
|
55
60
|
$status = $this->call('migrate');
|
|
56
61
|
}
|
|
62
|
+
if ($status === self::SUCCESS && $seed) {
|
|
63
|
+
$status = $this->call('nevela:seed', ['name' => $descriptor->name, '--count' => $this->option('seed') ?: 25]);
|
|
64
|
+
}
|
|
57
65
|
|
|
58
66
|
$this->newLine();
|
|
59
|
-
$this->line($
|
|
67
|
+
$this->line($migrate
|
|
60
68
|
? ' Next: tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.'
|
|
61
69
|
: ' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
|
|
62
70
|
$this->line(" API: GET|POST /{$this->prefix()}{$descriptor->slug} · GET|PUT|PATCH|DELETE /{$this->prefix()}{$descriptor->slug}/{id} · GET /{$this->prefix()}{$descriptor->slug}/_stats");
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Support\Facades\DB;
|
|
7
|
+
use Illuminate\Support\Facades\Http;
|
|
8
|
+
use Nevela\Laravel\Nevela;
|
|
9
|
+
use Nevela\Laravel\Support\Releases;
|
|
10
|
+
use Throwable;
|
|
11
|
+
|
|
12
|
+
final class StatusCommand extends Command
|
|
13
|
+
{
|
|
14
|
+
protected $signature = 'nevela:status';
|
|
15
|
+
|
|
16
|
+
protected $description = 'Check the app: versions, the database, migrations, users, resources and the dashboard';
|
|
17
|
+
|
|
18
|
+
public function handle(): int
|
|
19
|
+
{
|
|
20
|
+
$problems = 0;
|
|
21
|
+
$row = fn (string $label, string $value) => $this->components->twoColumnDetail($label, $value);
|
|
22
|
+
$bad = function (string $text) use (&$problems): string {
|
|
23
|
+
$problems++;
|
|
24
|
+
|
|
25
|
+
return "<fg=red>{$text}</>";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
$this->newLine();
|
|
29
|
+
$latest = Releases::latest();
|
|
30
|
+
$row('Nevela', Nevela::VERSION.match (true) {
|
|
31
|
+
$latest === null => ' <fg=gray>(couldn\'t check for a newer one)</>',
|
|
32
|
+
version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela update)</>",
|
|
33
|
+
default => ' <fg=green>(latest)</>',
|
|
34
|
+
});
|
|
35
|
+
$row('Laravel / PHP', app()->version().' / '.PHP_VERSION);
|
|
36
|
+
|
|
37
|
+
// --- database ---
|
|
38
|
+
try {
|
|
39
|
+
$connection = DB::connection();
|
|
40
|
+
$connection->getPdo();
|
|
41
|
+
$name = $connection->getDatabaseName();
|
|
42
|
+
$row('Database', $connection->getDriverName().($name ? ' · '.str_replace(base_path().DIRECTORY_SEPARATOR, '', (string) $name) : ''));
|
|
43
|
+
|
|
44
|
+
$migrator = app('migrator');
|
|
45
|
+
if (! $migrator->repositoryExists()) {
|
|
46
|
+
$row('Migrations', $bad('none have run: php nevela migrate'));
|
|
47
|
+
} else {
|
|
48
|
+
$ran = $migrator->getRepository()->getRan();
|
|
49
|
+
$files = array_keys($migrator->getMigrationFiles([...$migrator->paths(), database_path('migrations')]));
|
|
50
|
+
$pending = array_values(array_diff($files, $ran));
|
|
51
|
+
$row('Migrations', count($ran).' ran, '.($pending === []
|
|
52
|
+
? '<fg=green>none pending</>'
|
|
53
|
+
: $bad(count($pending).' pending: php nevela migrate')));
|
|
54
|
+
foreach ($pending as $migration) {
|
|
55
|
+
$this->line(" <fg=gray>pending</> {$migration}");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
$model = config('auth.providers.users.model');
|
|
60
|
+
if (is_string($model) && class_exists($model)) {
|
|
61
|
+
try {
|
|
62
|
+
$users = $model::query()->count();
|
|
63
|
+
$row('Users who can sign in', $users > 0 ? (string) $users : $bad('none: php nevela user'));
|
|
64
|
+
} catch (Throwable) {
|
|
65
|
+
$row('Users who can sign in', $bad('the users table is missing: php nevela migrate'));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (Throwable $e) {
|
|
69
|
+
$row('Database', $bad('can\'t connect: '.str($e->getMessage())->limit(90)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- resources ---
|
|
73
|
+
try {
|
|
74
|
+
$resources = Nevela::all();
|
|
75
|
+
} catch (Throwable $e) {
|
|
76
|
+
$resources = [];
|
|
77
|
+
$row('Resources', $bad('a descriptor can\'t be read: '.str($e->getMessage())->limit(90)));
|
|
78
|
+
}
|
|
79
|
+
if ($resources === []) {
|
|
80
|
+
$row('Resources', '<fg=gray>none yet: php nevela resource Product --fields="name:string"</>');
|
|
81
|
+
}
|
|
82
|
+
foreach ($resources as $resource) {
|
|
83
|
+
try {
|
|
84
|
+
$row("Resource: {$resource->name}", number_format(DB::table($resource->table)->count()).' records');
|
|
85
|
+
} catch (Throwable) {
|
|
86
|
+
$row("Resource: {$resource->name}", $bad("the {$resource->table} table is missing: php nevela migrate"));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- dashboard ---
|
|
91
|
+
$web = config('nevela.web_path');
|
|
92
|
+
if (! $web || ! is_dir($web)) {
|
|
93
|
+
$row('Dashboard', '<fg=gray>not found at nevela.web_path</>');
|
|
94
|
+
} else {
|
|
95
|
+
$marker = json_decode((string) @file_get_contents(rtrim($web, '/\\').'/.nevela.json'), true);
|
|
96
|
+
$row('Dashboard', is_array($marker) && isset($marker['template']) ? "template {$marker['template']}" : '<fg=gray>template version not recorded</>');
|
|
97
|
+
|
|
98
|
+
// `php nevela dev` may have put the API on another port and told the dashboard so,
|
|
99
|
+
// which is fine. Look for this app wherever it is running before judging.
|
|
100
|
+
$url = self::apiUrl($web);
|
|
101
|
+
$who = self::whoAnswers($url);
|
|
102
|
+
$running = $who === 'this' ? $url : self::findRunning();
|
|
103
|
+
|
|
104
|
+
if ($who === 'this') {
|
|
105
|
+
$row('API', "{$url} <fg=green>(running, and it is this app)</>");
|
|
106
|
+
} elseif ($running !== null) {
|
|
107
|
+
$row('API', "{$running} <fg=green>(running, and it is this app)</>");
|
|
108
|
+
$row('', '<fg=gray>'.parse_url($url, PHP_URL_PORT).' belongs to another program; php nevela dev chose a free port</>');
|
|
109
|
+
} elseif ($who === 'nobody') {
|
|
110
|
+
$row('API', '<fg=gray>not running: php nevela dev</>');
|
|
111
|
+
} else {
|
|
112
|
+
$row('API', $bad("{$url} is answered by ".($who === 'nevela' ? 'another Nevela app' : 'another program').', not this app'));
|
|
113
|
+
$row('', '<fg=gray>start with php nevela dev, which picks a free port and tells the dashboard</>');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
$this->newLine();
|
|
117
|
+
|
|
118
|
+
if ($problems > 0) {
|
|
119
|
+
$this->components->warn($problems === 1 ? 'One thing needs attention, marked in red above.' : "{$problems} things need attention, marked in red above.");
|
|
120
|
+
|
|
121
|
+
return self::FAILURE;
|
|
122
|
+
}
|
|
123
|
+
$this->components->info('Everything looks right.');
|
|
124
|
+
|
|
125
|
+
return self::SUCCESS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where the dashboard is told the API is: NEVELA_API_URL in its .env.local, else the default. */
|
|
129
|
+
public static function apiUrl(string $web): string
|
|
130
|
+
{
|
|
131
|
+
$env = (string) @file_get_contents(rtrim($web, '/\\').'/.env.local');
|
|
132
|
+
if (preg_match('/^\s*NEVELA_API_URL\s*=\s*["\']?([^"\'\r\n#]+)/m', $env, $m)) {
|
|
133
|
+
return rtrim(trim($m[1]), '/');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return 'http://127.0.0.1:8000/api';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Who answers at an API address: 'this' app, another 'nevela' app, some 'other' program, or 'nobody'. */
|
|
140
|
+
public static function whoAnswers(string $url): string
|
|
141
|
+
{
|
|
142
|
+
try {
|
|
143
|
+
$response = Http::timeout(3)->acceptJson()->get($url.'/_nevela/ping');
|
|
144
|
+
} catch (Throwable) {
|
|
145
|
+
return 'nobody';
|
|
146
|
+
}
|
|
147
|
+
if ($response->json('app') === Nevela::fingerprint()) {
|
|
148
|
+
return 'this';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return $response->json('nevela') !== null ? 'nevela' : 'other';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** This app's API on one of the ports `php nevela dev` would use, or null if it isn't running. */
|
|
155
|
+
private static function findRunning(): ?string
|
|
156
|
+
{
|
|
157
|
+
$prefix = trim((string) config('nevela.prefix', 'api'), '/');
|
|
158
|
+
for ($port = 8000; $port < 8020; $port++) {
|
|
159
|
+
$socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.15);
|
|
160
|
+
if ($socket === false) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
fclose($socket);
|
|
164
|
+
$url = "http://127.0.0.1:{$port}".($prefix === '' ? '' : "/{$prefix}");
|
|
165
|
+
if (self::whoAnswers($url) === 'this') {
|
|
166
|
+
return $url;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -6,6 +6,7 @@ use Illuminate\Console\Command;
|
|
|
6
6
|
use Illuminate\Support\Facades\Http;
|
|
7
7
|
use Nevela\Laravel\Nevela;
|
|
8
8
|
use Nevela\Laravel\Support\DashboardUpdate;
|
|
9
|
+
use Nevela\Laravel\Support\Releases;
|
|
9
10
|
use PharData;
|
|
10
11
|
use RecursiveDirectoryIterator;
|
|
11
12
|
use RecursiveIteratorIterator;
|
|
@@ -37,11 +38,32 @@ final class UpdateCommand extends Command
|
|
|
37
38
|
|
|
38
39
|
if (! $check) {
|
|
39
40
|
$this->components->task('Regenerating from the descriptors', fn () => $this->callSilently('nevela:generate') === self::SUCCESS);
|
|
41
|
+
$this->modernizeDevScript();
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
return $this->updateDashboard($check);
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Apps created before 0.1.3 run both servers with scripts/dev.mjs, which always uses
|
|
49
|
+
* port 8000 and so can end up talking to another program that already has it. Point
|
|
50
|
+
* their `dev` script at `php nevela dev`, which picks a free port, unless they changed it.
|
|
51
|
+
*/
|
|
52
|
+
private function modernizeDevScript(): void
|
|
53
|
+
{
|
|
54
|
+
$root = GenerateCommand::projectRoot();
|
|
55
|
+
$file = $root ? $root.DIRECTORY_SEPARATOR.'package.json' : null;
|
|
56
|
+
if (! $file || ! is_file($file)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
$source = (string) file_get_contents($file);
|
|
60
|
+
$updated = preg_replace('/("dev"\s*:\s*)"node scripts\/dev\.mjs"/', '$1"php nevela dev"', $source, 1, $count);
|
|
61
|
+
if ($count === 1) {
|
|
62
|
+
file_put_contents($file, $updated);
|
|
63
|
+
$this->components->twoColumnDetail('package.json: dev', '<fg=blue>node scripts/dev.mjs → php nevela dev</>');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
/**
|
|
46
68
|
* Update the Composer package. Returns an exit code when the rest of the work was
|
|
47
69
|
* handed to a fresh process, or null to carry on in this one.
|
|
@@ -49,7 +71,7 @@ final class UpdateCommand extends Command
|
|
|
49
71
|
private function updatePackage(bool $check): ?int
|
|
50
72
|
{
|
|
51
73
|
$installed = Nevela::VERSION;
|
|
52
|
-
$latest =
|
|
74
|
+
$latest = Releases::latest();
|
|
53
75
|
|
|
54
76
|
if ($folder = $this->installedByPath()) {
|
|
55
77
|
$this->components->warn("nevela/laravel is installed from a folder ({$folder}), so Composer can't update it. The dashboard is checked against the version you have, {$installed}.");
|
|
@@ -115,7 +137,7 @@ final class UpdateCommand extends Command
|
|
|
115
137
|
$marker = "{$web}/.nevela.json";
|
|
116
138
|
$from = is_file($marker) ? (json_decode((string) file_get_contents($marker), true)['template'] ?? self::FIRST_TRACKED) : self::FIRST_TRACKED;
|
|
117
139
|
// In --check the package hasn't been updated, so look at the newest dashboard there is.
|
|
118
|
-
$to = $check ? (
|
|
140
|
+
$to = $check ? (Releases::latest() ?? Nevela::VERSION) : Nevela::VERSION;
|
|
119
141
|
|
|
120
142
|
if (version_compare($from, $to, '>=')) {
|
|
121
143
|
$this->components->info("The dashboard is on the latest template ({$from}).");
|
|
@@ -246,29 +268,6 @@ final class UpdateCommand extends Command
|
|
|
246
268
|
}
|
|
247
269
|
}
|
|
248
270
|
|
|
249
|
-
/** The newest stable release on Packagist, or null when it can't be reached. */
|
|
250
|
-
private function latestOnPackagist(): ?string
|
|
251
|
-
{
|
|
252
|
-
static $latest = false;
|
|
253
|
-
if ($latest !== false) {
|
|
254
|
-
return $latest;
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
$releases = Http::timeout(10)->get('https://repo.packagist.org/p2/nevela/laravel.json')->json('packages.nevela/laravel') ?? [];
|
|
258
|
-
} catch (Throwable) {
|
|
259
|
-
return $latest = null;
|
|
260
|
-
}
|
|
261
|
-
$versions = [];
|
|
262
|
-
foreach ($releases as $release) {
|
|
263
|
-
if (preg_match('/^v?(\d+\.\d+\.\d+)$/', (string) ($release['version'] ?? ''), $m)) {
|
|
264
|
-
$versions[] = $m[1];
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
usort($versions, 'version_compare');
|
|
268
|
-
|
|
269
|
-
return $latest = ($versions === [] ? null : end($versions));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
271
|
/** The folder Composer installs the package from, when it isn't coming from Packagist. */
|
|
273
272
|
private function installedByPath(): ?string
|
|
274
273
|
{
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Nevela\Laravel\Nevela;
|
|
7
|
+
|
|
8
|
+
final class VersionCommand extends Command
|
|
9
|
+
{
|
|
10
|
+
protected $signature = 'nevela:version';
|
|
11
|
+
|
|
12
|
+
protected $description = 'Show which version of Nevela this app is on';
|
|
13
|
+
|
|
14
|
+
public function handle(): int
|
|
15
|
+
{
|
|
16
|
+
$this->line('Nevela '.Nevela::VERSION);
|
|
17
|
+
|
|
18
|
+
return self::SUCCESS;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -172,13 +172,10 @@ final class ResourceGenerator
|
|
|
172
172
|
\$name = array_shift(\$args) ?? 'help';
|
|
173
173
|
|
|
174
174
|
// What you type => the artisan command it runs.
|
|
175
|
-
\$nevela = ['resource', 'generate', 'seed', 'user', 'update'];
|
|
175
|
+
\$nevela = ['resource', 'generate', 'seed', 'user', 'update', 'dev', 'status', 'version'];
|
|
176
176
|
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// The API and the dashboard together.
|
|
180
|
-
passthru('node '.escapeshellarg(__DIR__.'/scripts/dev.mjs'), \$status);
|
|
181
|
-
exit(\$status);
|
|
177
|
+
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
178
|
+
\$name = 'version';
|
|
182
179
|
}
|
|
183
180
|
|
|
184
181
|
if (in_array(\$name, \$nevela, true)) {
|
|
@@ -197,12 +194,15 @@ final class ResourceGenerator
|
|
|
197
194
|
|
|
198
195
|
Nevela, from the top of your project.
|
|
199
196
|
|
|
200
|
-
php nevela
|
|
197
|
+
php nevela dev run the API and the dashboard
|
|
198
|
+
php nevela status check versions, migrations, users and the dashboard
|
|
199
|
+
php nevela resource Product --fields="name:string, price:money" --seed
|
|
200
|
+
add a resource, create its table, fill it
|
|
201
201
|
php nevela generate regenerate after editing a descriptor
|
|
202
202
|
php nevela seed Product fill a resource with records
|
|
203
203
|
php nevela user create someone who can sign in
|
|
204
204
|
php nevela update update Nevela and the dashboard
|
|
205
|
-
php nevela
|
|
205
|
+
php nevela version which Nevela this is
|
|
206
206
|
|
|
207
207
|
php nevela migrate php artisan migrate
|
|
208
208
|
php nevela artisan <command> any other artisan command
|
|
@@ -1,151 +1,161 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
-
use Illuminate\Http\JsonResponse;
|
|
7
|
-
use Illuminate\Http\Request;
|
|
8
|
-
use InvalidArgumentException;
|
|
9
|
-
use Nevela\Laravel\Support\Descriptor;
|
|
10
|
-
use Nevela\Laravel\Support\ListQuery;
|
|
11
|
-
use Nevela\Laravel\Support\Naming;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Runtime helpers the generated controllers call. Kept small and readable: each method is
|
|
15
|
-
* one step of Flare's REST contract.
|
|
16
|
-
*/
|
|
17
|
-
final class Nevela
|
|
18
|
-
{
|
|
19
|
-
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
20
|
-
public const VERSION = '0.1.
|
|
21
|
-
|
|
22
|
-
/** @var array<string, Descriptor> */
|
|
23
|
-
private static array $descriptors = [];
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
public static function
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
$
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
$
|
|
128
|
-
if ($
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
'
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
+
use Illuminate\Http\JsonResponse;
|
|
7
|
+
use Illuminate\Http\Request;
|
|
8
|
+
use InvalidArgumentException;
|
|
9
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
10
|
+
use Nevela\Laravel\Support\ListQuery;
|
|
11
|
+
use Nevela\Laravel\Support\Naming;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime helpers the generated controllers call. Kept small and readable: each method is
|
|
15
|
+
* one step of Flare's REST contract.
|
|
16
|
+
*/
|
|
17
|
+
final class Nevela
|
|
18
|
+
{
|
|
19
|
+
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
20
|
+
public const VERSION = '0.1.3';
|
|
21
|
+
|
|
22
|
+
/** @var array<string, Descriptor> */
|
|
23
|
+
private static array $descriptors = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A short id for this installation, from where it is on disk. It gives nothing away,
|
|
27
|
+
* and it differs between two apps on one machine, which is what it is for: telling
|
|
28
|
+
* which app is answering on a port.
|
|
29
|
+
*/
|
|
30
|
+
public static function fingerprint(): string
|
|
31
|
+
{
|
|
32
|
+
return substr(sha1(base_path()), 0, 12);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A resource's descriptor, from config('nevela.descriptors_path')/<kebab-name>.json. */
|
|
36
|
+
public static function resource(string $name): Descriptor
|
|
37
|
+
{
|
|
38
|
+
if (isset(self::$descriptors[$name])) {
|
|
39
|
+
return self::$descriptors[$name];
|
|
40
|
+
}
|
|
41
|
+
$path = rtrim(config('nevela.descriptors_path'), '/\\').DIRECTORY_SEPARATOR.Naming::kebab($name).'.json';
|
|
42
|
+
if (! is_file($path)) {
|
|
43
|
+
throw new InvalidArgumentException("No Nevela descriptor for {$name} at {$path}.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return self::$descriptors[$name] = Descriptor::fromJson((string) file_get_contents($path));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @return list<Descriptor> */
|
|
50
|
+
public static function all(): array
|
|
51
|
+
{
|
|
52
|
+
$dir = config('nevela.descriptors_path');
|
|
53
|
+
$all = [];
|
|
54
|
+
foreach (glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.json') ?: [] as $file) {
|
|
55
|
+
$d = Descriptor::fromJson((string) file_get_contents($file));
|
|
56
|
+
$all[] = self::$descriptors[$d->name] = $d;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return $all;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static function forget(): void
|
|
63
|
+
{
|
|
64
|
+
self::$descriptors = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* GET /{slug}: page, perPage, sort, q, filter[field] → { data, meta }.
|
|
69
|
+
*
|
|
70
|
+
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
|
|
71
|
+
*/
|
|
72
|
+
public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
|
|
73
|
+
{
|
|
74
|
+
$resource = self::resource($name);
|
|
75
|
+
$list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
|
|
76
|
+
if (! $list->ok()) {
|
|
77
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if ($list->q !== null) {
|
|
81
|
+
$columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
|
|
82
|
+
if ($columns !== []) {
|
|
83
|
+
$pattern = '%'.addcslashes($list->q, '%_\\').'%';
|
|
84
|
+
$query->where(function (Builder $where) use ($columns, $pattern) {
|
|
85
|
+
foreach ($columns as $column) {
|
|
86
|
+
$where->orWhereLike($column, $pattern);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
foreach ($list->filters as $filter) {
|
|
93
|
+
$filter['value'] === null
|
|
94
|
+
? $query->whereNull($filter['column'])
|
|
95
|
+
: $query->where($filter['column'], $filter['value']);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$query->reorder()
|
|
99
|
+
->orderBy($list->sort['column'], $list->sort['direction'])
|
|
100
|
+
->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
|
|
101
|
+
|
|
102
|
+
$page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
|
|
103
|
+
|
|
104
|
+
return response()->json([
|
|
105
|
+
'data' => $resourceClass::collection($page->getCollection())->resolve($request),
|
|
106
|
+
'meta' => [
|
|
107
|
+
'page' => $page->currentPage(),
|
|
108
|
+
'perPage' => $page->perPage(),
|
|
109
|
+
'total' => $page->total(),
|
|
110
|
+
'totalPages' => max(1, $page->lastPage()),
|
|
111
|
+
],
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
|
|
117
|
+
* What Flare's ResourceStats card shows above a table.
|
|
118
|
+
*/
|
|
119
|
+
public static function stats(Builder $query, string $name, Request $request): JsonResponse
|
|
120
|
+
{
|
|
121
|
+
$resource = self::resource($name);
|
|
122
|
+
$days = filter_var($request->query('days', 7), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 365]]);
|
|
123
|
+
if ($days === false) {
|
|
124
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'days', 'message' => 'Must be between 1 and 365.']]], 400);
|
|
125
|
+
}
|
|
126
|
+
$key = $request->query('field');
|
|
127
|
+
$field = is_string($key) ? ($resource->fields[$key] ?? null) : null;
|
|
128
|
+
if ($key !== null && ($field === null || ! in_array($field->kind, ['enum', 'boolean'], true))) {
|
|
129
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'field', 'message' => 'Must be an enum or boolean field.']]], 400);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$now = now();
|
|
133
|
+
$since = $now->copy()->subDays($days);
|
|
134
|
+
$before = $now->copy()->subDays($days * 2);
|
|
135
|
+
$base = fn () => (clone $query)->reorder();
|
|
136
|
+
|
|
137
|
+
$values = [];
|
|
138
|
+
if ($field !== null) {
|
|
139
|
+
$column = $field->column();
|
|
140
|
+
foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
|
|
141
|
+
$value = $field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value;
|
|
142
|
+
$values[$value] = (int) $row->aggregate;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return response()->json([
|
|
147
|
+
'total' => $base()->count(),
|
|
148
|
+
'current' => $base()->where('created_at', '>=', $since)->count(),
|
|
149
|
+
'previous' => $base()->where('created_at', '>=', $before)->where('created_at', '<', $since)->count(),
|
|
150
|
+
'values' => (object) $values,
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
155
|
+
public static function handles(Request $request): bool
|
|
156
|
+
{
|
|
157
|
+
$prefix = trim((string) config('nevela.prefix', 'api'), '/');
|
|
158
|
+
|
|
159
|
+
return $prefix === '' || $request->is($prefix, $prefix.'/*');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -5,11 +5,14 @@ namespace Nevela\Laravel;
|
|
|
5
5
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
6
6
|
use Illuminate\Support\Facades\Route;
|
|
7
7
|
use Illuminate\Support\ServiceProvider;
|
|
8
|
+
use Nevela\Laravel\Console\DevCommand;
|
|
8
9
|
use Nevela\Laravel\Console\GenerateCommand;
|
|
9
10
|
use Nevela\Laravel\Console\MakeResourceCommand;
|
|
10
11
|
use Nevela\Laravel\Console\SeedCommand;
|
|
12
|
+
use Nevela\Laravel\Console\StatusCommand;
|
|
11
13
|
use Nevela\Laravel\Console\UpdateCommand;
|
|
12
14
|
use Nevela\Laravel\Console\UserCommand;
|
|
15
|
+
use Nevela\Laravel\Console\VersionCommand;
|
|
13
16
|
use Nevela\Laravel\Http\FlareErrors;
|
|
14
17
|
use Nevela\Laravel\Http\TokenController;
|
|
15
18
|
|
|
@@ -24,7 +27,10 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
24
27
|
{
|
|
25
28
|
if ($this->app->runningInConsole()) {
|
|
26
29
|
$this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
|
|
27
|
-
$this->commands([
|
|
30
|
+
$this->commands([
|
|
31
|
+
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
32
|
+
UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class,
|
|
33
|
+
]);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
$this->callAfterResolving(ExceptionHandler::class, function ($handler) {
|
|
@@ -52,6 +58,13 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
52
58
|
});
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
// Open to anyone, and says only that this is a Nevela app and which one. It is how the
|
|
62
|
+
// dashboard and `nevela:status` tell this app from another program on the same port.
|
|
63
|
+
Route::prefix($prefix)->middleware(['api', 'throttle:60,1'])->get('_nevela/ping', fn () => response()->json([
|
|
64
|
+
'nevela' => Nevela::VERSION,
|
|
65
|
+
'app' => Nevela::fingerprint(),
|
|
66
|
+
]))->name('nevela.ping');
|
|
67
|
+
|
|
55
68
|
Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
|
|
56
69
|
// The descriptors, so the web app (and tooling) can check it matches the API.
|
|
57
70
|
Route::get('_nevela/resources', fn () => response()->json([
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Http;
|
|
6
|
+
use Throwable;
|
|
7
|
+
|
|
8
|
+
/** What Packagist knows about nevela/laravel. */
|
|
9
|
+
final class Releases
|
|
10
|
+
{
|
|
11
|
+
private static string|null|false $latest = false;
|
|
12
|
+
|
|
13
|
+
/** The newest stable release, or null when Packagist can't be reached. Asked once per run. */
|
|
14
|
+
public static function latest(): ?string
|
|
15
|
+
{
|
|
16
|
+
if (self::$latest !== false) {
|
|
17
|
+
return self::$latest;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
$releases = Http::timeout(8)->get('https://repo.packagist.org/p2/nevela/laravel.json')->json('packages.nevela/laravel') ?? [];
|
|
21
|
+
} catch (Throwable) {
|
|
22
|
+
return self::$latest = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return self::$latest = self::newest(array_map(fn ($release) => (string) ($release['version'] ?? ''), $releases));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The highest x.y.z in a list of version names. Branches and pre-releases don't count.
|
|
30
|
+
*
|
|
31
|
+
* @param list<string> $names
|
|
32
|
+
*/
|
|
33
|
+
public static function newest(array $names): ?string
|
|
34
|
+
{
|
|
35
|
+
$versions = [];
|
|
36
|
+
foreach ($names as $name) {
|
|
37
|
+
if (preg_match('/^v?(\d+\.\d+\.\d+)$/', $name, $m)) {
|
|
38
|
+
$versions[] = $m[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
usort($versions, 'version_compare');
|
|
42
|
+
|
|
43
|
+
return $versions === [] ? null : end($versions);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
3
|
import { cookies } from "next/headers";
|
|
4
|
-
import { laravel, TOKEN_COOKIE } from "@/lib/laravel";
|
|
4
|
+
import { API_URL, laravel, TOKEN_COOKIE } from "@/lib/laravel";
|
|
5
5
|
|
|
6
6
|
export type SignInResult = { ok: true } | { ok: false; error: string };
|
|
7
7
|
|
|
@@ -17,12 +17,20 @@ export async function signInAction(email: string, password: string): Promise<Sig
|
|
|
17
17
|
try {
|
|
18
18
|
response = await laravel("auth/token", { method: "POST", body: { email, password }, token: null });
|
|
19
19
|
} catch {
|
|
20
|
-
return { ok: false, error:
|
|
20
|
+
return { ok: false, error: `Can't reach the server at ${API_URL}. Start it with: php nevela dev` };
|
|
21
21
|
}
|
|
22
22
|
if (!response.ok) {
|
|
23
|
-
const body = (response.body ?? {}) as { error?: string; issues?: { message: string }[] };
|
|
23
|
+
const body = (response.body ?? {}) as { error?: string; message?: string; issues?: { message: string }[] };
|
|
24
24
|
if (response.status === 429) return { ok: false, error: "Too many attempts. Wait a minute and try again." };
|
|
25
|
-
|
|
25
|
+
if (response.status === 404 || response.status === 405) {
|
|
26
|
+
// Something answered, but it has no sign-in route: it isn't this app's API. Usually
|
|
27
|
+
// another program already had the port when the API was started.
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: `${API_URL} is answered by a different program, not this app's API. Stop what is using that port, or start both with "php nevela dev", which picks a free one.`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { ok: false, error: body.issues?.[0]?.message ?? body.error ?? body.message ?? "Sign-in failed. Try again." };
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
const { token } = response.body as { token: string };
|