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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {
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
- * Whether Packagist has a release of nevela/laravel that goes with this installer. Being
14
- * listed isn't enough: with no tagged release yet, `composer require` would fail.
15
- */
16
- export async function onPackagist(wanted) {
17
- try {
18
- const response = await fetch('https://repo.packagist.org/p2/nevela/laravel.json', { signal: AbortSignal.timeout(8000) });
19
- if (!response.ok) return false;
20
- const releases = (await response.json()).packages?.['nevela/laravel'] ?? [];
21
- return releases.some((release) => satisfiesCaret(release.version, wanted));
22
- } catch {
23
- return false;
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
- /** @param list<Descriptor> $targets @param list<Descriptor> $all */
42
- public static function generate(Command $command, array $targets, array $all, bool $force): int
43
- {
44
- $generator = new ResourceGenerator;
45
- $files = [];
46
- foreach ($targets as $descriptor) {
47
- array_push($files, ...$generator->forResource($descriptor));
48
- }
49
- $files[] = $generator->routes($all);
50
- $files[] = $generator->registry($all);
51
-
52
- $roots = ['api' => base_path()];
53
- if (($web = config('nevela.web_path')) && is_dir($web)) {
54
- $roots['web'] = $web;
55
- }
56
-
57
- $report = (new Writer($roots, $force))->write($files);
58
- $edited = 0;
59
- foreach ($report as $row) {
60
- $path = str_replace([base_path().DIRECTORY_SEPARATOR, '\\'], ['', '/'], $row['path']);
61
- $command->components->twoColumnDetail($path, match ($row['status']) {
62
- 'created' => '<fg=green>created</>',
63
- 'updated' => '<fg=blue>updated</>',
64
- 'edited' => '<fg=yellow>skipped — edited inside generated block</>',
65
- 'exists' => '<fg=gray>exists (yours)</>',
66
- 'skipped' => '<fg=gray>skipped (no web app at nevela.web_path yet)</>',
67
- default => '<fg=gray>unchanged</>',
68
- });
69
- $edited += $row['status'] === 'edited' ? 1 : 0;
70
- }
71
- if ($edited) {
72
- $command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
73
- }
74
-
75
- return self::SUCCESS;
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(' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
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
+ }