create-nevela 0.1.1 → 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 +64 -55
- package/index.mjs +405 -418
- package/package.json +1 -1
- package/packagist.mjs +34 -25
- package/template/laravel/README.md +1 -0
- package/template/laravel/config/nevela.php +7 -0
- package/template/laravel/src/Console/DevCommand.php +104 -0
- package/template/laravel/src/Console/GenerateCommand.php +107 -77
- package/template/laravel/src/Console/MakeResourceCommand.php +17 -2
- package/template/laravel/src/Console/StatusCommand.php +172 -0
- package/template/laravel/src/Console/UpdateCommand.php +303 -0
- package/template/laravel/src/Console/VersionCommand.php +20 -0
- package/template/laravel/src/Generator/GeneratedFile.php +11 -2
- package/template/laravel/src/Generator/ResourceGenerator.php +719 -639
- package/template/laravel/src/Nevela.php +161 -151
- package/template/laravel/src/NevelaServiceProvider.php +79 -65
- package/template/laravel/src/Support/DashboardUpdate.php +110 -0
- 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/package.json
CHANGED
package/packagist.mjs
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
|
-
// Deciding where a new app gets nevela/laravel from.
|
|
2
|
-
|
|
3
|
-
/** Whether `candidate` is a stable release Composer's `^major.minor` would accept. */
|
|
4
|
-
export function satisfiesCaret(candidate, [major, minor]) {
|
|
5
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
|
|
6
|
-
if (!match) return false;
|
|
7
|
-
const [theirMajor, theirMinor] = match.slice(1).map(Number);
|
|
8
|
-
// Below 1.0 a caret holds the minor version too: ^0.1 means 0.1.x only.
|
|
9
|
-
return major === 0 ? theirMajor === 0 && theirMinor === minor : theirMajor === major && theirMinor >= minor;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
// Deciding where a new app gets nevela/laravel from.
|
|
2
|
+
|
|
3
|
+
/** Whether `candidate` is a stable release Composer's `^major.minor` would accept. */
|
|
4
|
+
export function satisfiesCaret(candidate, [major, minor]) {
|
|
5
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
|
|
6
|
+
if (!match) return false;
|
|
7
|
+
const [theirMajor, theirMinor] = match.slice(1).map(Number);
|
|
8
|
+
// Below 1.0 a caret holds the minor version too: ^0.1 means 0.1.x only.
|
|
9
|
+
return major === 0 ? theirMajor === 0 && theirMinor === minor : theirMajor === major && theirMinor >= minor;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Whether stable version `a` is newer than `b`. Anything that isn't x.y.z is never newer. */
|
|
13
|
+
export function isNewer(a, b) {
|
|
14
|
+
const parse = (version) => /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version))?.slice(1).map(Number);
|
|
15
|
+
const [left, right] = [parse(a), parse(b)];
|
|
16
|
+
if (!left || !right) return false;
|
|
17
|
+
for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i];
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether Packagist has a release of nevela/laravel that goes with this installer. Being
|
|
23
|
+
* listed isn't enough: with no tagged release yet, `composer require` would fail.
|
|
24
|
+
*/
|
|
25
|
+
export async function onPackagist(wanted) {
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch('https://repo.packagist.org/p2/nevela/laravel.json', { signal: AbortSignal.timeout(8000) });
|
|
28
|
+
if (!response.ok) return false;
|
|
29
|
+
const releases = (await response.json()).packages?.['nevela/laravel'] ?? [];
|
|
30
|
+
return releases.some((release) => satisfiesCaret(release.version, wanted));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -43,6 +43,7 @@ Needs PHP 8.2 or newer and Laravel 11, 12 or 13.
|
|
|
43
43
|
| `nevela:generate` | Regenerate after changing a descriptor. Your own code is kept. |
|
|
44
44
|
| `nevela:seed` | Fill a resource with plausible records, and print how long it took. |
|
|
45
45
|
| `nevela:user` | Create someone who can sign in. |
|
|
46
|
+
| `nevela:update` | Update the package, the generated code and the dashboard. |
|
|
46
47
|
|
|
47
48
|
Full documentation: https://nevela-docs.vercel.app
|
|
48
49
|
|
|
@@ -13,6 +13,13 @@ return [
|
|
|
13
13
|
*/
|
|
14
14
|
'web_path' => env('NEVELA_WEB_PATH', base_path('../web')),
|
|
15
15
|
|
|
16
|
+
/*
|
|
17
|
+
| The top of the project, where the `php nevela` launcher is written. Null works it
|
|
18
|
+
| out: two folders up when this app is at <project>/apps/<name>. Set a path if yours
|
|
19
|
+
| is somewhere else, or false to have no launcher.
|
|
20
|
+
*/
|
|
21
|
+
'root_path' => env('NEVELA_ROOT_PATH'),
|
|
22
|
+
|
|
16
23
|
/* URL prefix and middleware for generated resource routes (routes/nevela.php). */
|
|
17
24
|
'prefix' => 'api',
|
|
18
25
|
'middleware' => ['api', 'auth:sanctum'],
|
|
@@ -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
|
+
}
|
|
@@ -1,77 +1,107 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel\Console;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Console\Command;
|
|
6
|
-
use InvalidArgumentException;
|
|
7
|
-
use Nevela\Laravel\Generator\ResourceGenerator;
|
|
8
|
-
use Nevela\Laravel\Generator\Writer;
|
|
9
|
-
use Nevela\Laravel\Nevela;
|
|
10
|
-
use Nevela\Laravel\Support\Descriptor;
|
|
11
|
-
|
|
12
|
-
final class GenerateCommand extends Command
|
|
13
|
-
{
|
|
14
|
-
protected $signature = 'nevela:generate
|
|
15
|
-
{name? : Only this resource (e.g. Product). Default: every descriptor}
|
|
16
|
-
{--force : Rewrite generated blocks even where you edited inside them}';
|
|
17
|
-
|
|
18
|
-
protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
|
|
19
|
-
|
|
20
|
-
public function handle(): int
|
|
21
|
-
{
|
|
22
|
-
Nevela::forget();
|
|
23
|
-
try {
|
|
24
|
-
$all = Nevela::all();
|
|
25
|
-
$targets = $this->argument('name') ? [Nevela::resource($this->argument('name'))] : $all;
|
|
26
|
-
} catch (InvalidArgumentException $e) {
|
|
27
|
-
$this->components->error($e->getMessage());
|
|
28
|
-
|
|
29
|
-
return self::FAILURE;
|
|
30
|
-
}
|
|
31
|
-
// With no descriptors there is still an (empty) route file and web registry to
|
|
32
|
-
// write, so a new app's dashboard builds before its first resource exists.
|
|
33
|
-
$status = self::generate($this, $targets, $all, (bool) $this->option('force'));
|
|
34
|
-
if ($all === []) {
|
|
35
|
-
$this->components->warn('No descriptors yet. Start with: php artisan nevela:resource Product --fields="name:string, price:money"');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return $status;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
$
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
$
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
$
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use InvalidArgumentException;
|
|
7
|
+
use Nevela\Laravel\Generator\ResourceGenerator;
|
|
8
|
+
use Nevela\Laravel\Generator\Writer;
|
|
9
|
+
use Nevela\Laravel\Nevela;
|
|
10
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
11
|
+
|
|
12
|
+
final class GenerateCommand extends Command
|
|
13
|
+
{
|
|
14
|
+
protected $signature = 'nevela:generate
|
|
15
|
+
{name? : Only this resource (e.g. Product). Default: every descriptor}
|
|
16
|
+
{--force : Rewrite generated blocks even where you edited inside them}';
|
|
17
|
+
|
|
18
|
+
protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
|
|
19
|
+
|
|
20
|
+
public function handle(): int
|
|
21
|
+
{
|
|
22
|
+
Nevela::forget();
|
|
23
|
+
try {
|
|
24
|
+
$all = Nevela::all();
|
|
25
|
+
$targets = $this->argument('name') ? [Nevela::resource($this->argument('name'))] : $all;
|
|
26
|
+
} catch (InvalidArgumentException $e) {
|
|
27
|
+
$this->components->error($e->getMessage());
|
|
28
|
+
|
|
29
|
+
return self::FAILURE;
|
|
30
|
+
}
|
|
31
|
+
// With no descriptors there is still an (empty) route file and web registry to
|
|
32
|
+
// write, so a new app's dashboard builds before its first resource exists.
|
|
33
|
+
$status = self::generate($this, $targets, $all, (bool) $this->option('force'));
|
|
34
|
+
if ($all === []) {
|
|
35
|
+
$this->components->warn('No descriptors yet. Start with: php artisan nevela:resource Product --fields="name:string, price:money"');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return $status;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The top of the project, where `php nevela` lives: nevela.root_path if set, otherwise
|
|
43
|
+
* two levels up when the app sits in <project>/apps/<name>. Null for a Laravel app that
|
|
44
|
+
* isn't inside such a project, which gets no launcher.
|
|
45
|
+
*/
|
|
46
|
+
public static function projectRoot(): ?string
|
|
47
|
+
{
|
|
48
|
+
$configured = config('nevela.root_path');
|
|
49
|
+
if ($configured === false) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (is_string($configured) && $configured !== '') {
|
|
53
|
+
return is_dir($configured) ? rtrim((string) realpath($configured), '/\\') : null;
|
|
54
|
+
}
|
|
55
|
+
$base = base_path();
|
|
56
|
+
|
|
57
|
+
return basename(dirname($base)) === 'apps' ? dirname($base, 2) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param list<Descriptor> $targets @param list<Descriptor> $all */
|
|
61
|
+
public static function generate(Command $command, array $targets, array $all, bool $force): int
|
|
62
|
+
{
|
|
63
|
+
$generator = new ResourceGenerator;
|
|
64
|
+
$files = [];
|
|
65
|
+
foreach ($targets as $descriptor) {
|
|
66
|
+
array_push($files, ...$generator->forResource($descriptor));
|
|
67
|
+
}
|
|
68
|
+
$files[] = $generator->routes($all);
|
|
69
|
+
$files[] = $generator->registry($all);
|
|
70
|
+
|
|
71
|
+
$roots = ['api' => base_path()];
|
|
72
|
+
if (($web = config('nevela.web_path')) && is_dir($web)) {
|
|
73
|
+
$roots['web'] = $web;
|
|
74
|
+
}
|
|
75
|
+
if ($root = self::projectRoot()) {
|
|
76
|
+
$roots['root'] = $root;
|
|
77
|
+
$apiFromRoot = ltrim(str_replace('\\', '/', substr(base_path(), strlen($root))), '/');
|
|
78
|
+
$files[] = $generator->launcher($apiFromRoot);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
$report = (new Writer($roots, $force))->write($files);
|
|
82
|
+
$edited = 0;
|
|
83
|
+
// Shown relative to the Laravel app, like the dashboard's files: ../../nevela
|
|
84
|
+
$rootShown = isset($apiFromRoot) ? rtrim(str_repeat('../', substr_count($apiFromRoot, '/') + 1), '/') : '';
|
|
85
|
+
foreach ($report as $row) {
|
|
86
|
+
$path = $row['path'];
|
|
87
|
+
if (isset($roots['root']) && str_starts_with($path, $roots['root'].DIRECTORY_SEPARATOR) && ! str_starts_with($path, base_path().DIRECTORY_SEPARATOR)) {
|
|
88
|
+
$path = $rootShown.substr($path, strlen($roots['root']));
|
|
89
|
+
}
|
|
90
|
+
$path = str_replace([base_path().DIRECTORY_SEPARATOR, '\\'], ['', '/'], $path);
|
|
91
|
+
$command->components->twoColumnDetail($path, match ($row['status']) {
|
|
92
|
+
'created' => '<fg=green>created</>',
|
|
93
|
+
'updated' => '<fg=blue>updated</>',
|
|
94
|
+
'edited' => '<fg=yellow>skipped — edited inside generated block</>',
|
|
95
|
+
'exists' => '<fg=gray>exists (yours)</>',
|
|
96
|
+
'skipped' => '<fg=gray>skipped (no web app at nevela.web_path yet)</>',
|
|
97
|
+
default => '<fg=gray>unchanged</>',
|
|
98
|
+
});
|
|
99
|
+
$edited += $row['status'] === 'edited' ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
if ($edited) {
|
|
102
|
+
$command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return self::SUCCESS;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -15,7 +15,9 @@ final class MakeResourceCommand extends Command
|
|
|
15
15
|
{--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
|
|
16
16
|
{--icon= : Lucide icon name for the dashboard, e.g. package}
|
|
17
17
|
{--group= : Sidebar group in the dashboard}
|
|
18
|
-
{--force : Overwrite an existing descriptor}
|
|
18
|
+
{--force : Overwrite an existing descriptor}
|
|
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}';
|
|
19
21
|
|
|
20
22
|
protected $description = 'Describe a resource once, then generate its Laravel API and Flare dashboard descriptor';
|
|
21
23
|
|
|
@@ -50,8 +52,21 @@ final class MakeResourceCommand extends Command
|
|
|
50
52
|
Nevela::forget();
|
|
51
53
|
$status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
|
|
52
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) {
|
|
60
|
+
$status = $this->call('migrate');
|
|
61
|
+
}
|
|
62
|
+
if ($status === self::SUCCESS && $seed) {
|
|
63
|
+
$status = $this->call('nevela:seed', ['name' => $descriptor->name, '--count' => $this->option('seed') ?: 25]);
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
$this->newLine();
|
|
54
|
-
$this->line(
|
|
67
|
+
$this->line($migrate
|
|
68
|
+
? ' Next: tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.'
|
|
69
|
+
: ' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
|
|
55
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");
|
|
56
71
|
|
|
57
72
|
return $status;
|
|
@@ -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
|
+
}
|