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 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
- └─ scripts/ `dev` runs both together
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
- pnpm run dev
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: 'node scripts/dev.mjs',
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
- ${pm} run dev
246
+ php nevela dev
291
247
  \`\`\`
292
248
 
293
- That starts Laravel on http://127.0.0.1:8000 and the dashboard on http://localhost:3000. Sign in at http://localhost:3000/sign-in.
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(` ${pm} run dev\n`);
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(` Everything runs from this folder: ${dim('php nevela')}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.1.2",
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": {
@@ -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
- if ($status === self::SUCCESS && $this->option('migrate')) {
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($this->option('migrate')
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 = $this->latestOnPackagist();
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 ? ($this->latestOnPackagist() ?? Nevela::VERSION) : Nevela::VERSION;
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
- if (\$name === 'dev') {
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 resource Product --fields="name:string, price:money" add a resource and migrate
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 dev run the API and the dashboard
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.2';
21
-
22
- /** @var array<string, Descriptor> */
23
- private static array $descriptors = [];
24
-
25
- /** A resource's descriptor, from config('nevela.descriptors_path')/<kebab-name>.json. */
26
- public static function resource(string $name): Descriptor
27
- {
28
- if (isset(self::$descriptors[$name])) {
29
- return self::$descriptors[$name];
30
- }
31
- $path = rtrim(config('nevela.descriptors_path'), '/\\').DIRECTORY_SEPARATOR.Naming::kebab($name).'.json';
32
- if (! is_file($path)) {
33
- throw new InvalidArgumentException("No Nevela descriptor for {$name} at {$path}.");
34
- }
35
-
36
- return self::$descriptors[$name] = Descriptor::fromJson((string) file_get_contents($path));
37
- }
38
-
39
- /** @return list<Descriptor> */
40
- public static function all(): array
41
- {
42
- $dir = config('nevela.descriptors_path');
43
- $all = [];
44
- foreach (glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.json') ?: [] as $file) {
45
- $d = Descriptor::fromJson((string) file_get_contents($file));
46
- $all[] = self::$descriptors[$d->name] = $d;
47
- }
48
-
49
- return $all;
50
- }
51
-
52
- public static function forget(): void
53
- {
54
- self::$descriptors = [];
55
- }
56
-
57
- /**
58
- * GET /{slug}: page, perPage, sort, q, filter[field] → { data, meta }.
59
- *
60
- * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
61
- */
62
- public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
63
- {
64
- $resource = self::resource($name);
65
- $list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
66
- if (! $list->ok()) {
67
- return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
68
- }
69
-
70
- if ($list->q !== null) {
71
- $columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
72
- if ($columns !== []) {
73
- $pattern = '%'.addcslashes($list->q, '%_\\').'%';
74
- $query->where(function (Builder $where) use ($columns, $pattern) {
75
- foreach ($columns as $column) {
76
- $where->orWhereLike($column, $pattern);
77
- }
78
- });
79
- }
80
- }
81
-
82
- foreach ($list->filters as $filter) {
83
- $filter['value'] === null
84
- ? $query->whereNull($filter['column'])
85
- : $query->where($filter['column'], $filter['value']);
86
- }
87
-
88
- $query->reorder()
89
- ->orderBy($list->sort['column'], $list->sort['direction'])
90
- ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
91
-
92
- $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
93
-
94
- return response()->json([
95
- 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
96
- 'meta' => [
97
- 'page' => $page->currentPage(),
98
- 'perPage' => $page->perPage(),
99
- 'total' => $page->total(),
100
- 'totalPages' => max(1, $page->lastPage()),
101
- ],
102
- ]);
103
- }
104
-
105
- /**
106
- * GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
107
- * What Flare's ResourceStats card shows above a table.
108
- */
109
- public static function stats(Builder $query, string $name, Request $request): JsonResponse
110
- {
111
- $resource = self::resource($name);
112
- $days = filter_var($request->query('days', 7), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 365]]);
113
- if ($days === false) {
114
- return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'days', 'message' => 'Must be between 1 and 365.']]], 400);
115
- }
116
- $key = $request->query('field');
117
- $field = is_string($key) ? ($resource->fields[$key] ?? null) : null;
118
- if ($key !== null && ($field === null || ! in_array($field->kind, ['enum', 'boolean'], true))) {
119
- return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'field', 'message' => 'Must be an enum or boolean field.']]], 400);
120
- }
121
-
122
- $now = now();
123
- $since = $now->copy()->subDays($days);
124
- $before = $now->copy()->subDays($days * 2);
125
- $base = fn () => (clone $query)->reorder();
126
-
127
- $values = [];
128
- if ($field !== null) {
129
- $column = $field->column();
130
- foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
131
- $value = $field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value;
132
- $values[$value] = (int) $row->aggregate;
133
- }
134
- }
135
-
136
- return response()->json([
137
- 'total' => $base()->count(),
138
- 'current' => $base()->where('created_at', '>=', $since)->count(),
139
- 'previous' => $base()->where('created_at', '>=', $before)->where('created_at', '<', $since)->count(),
140
- 'values' => (object) $values,
141
- ]);
142
- }
143
-
144
- /** Whether a request is one Nevela answers (so errors use Flare's shape). */
145
- public static function handles(Request $request): bool
146
- {
147
- $prefix = trim((string) config('nevela.prefix', 'api'), '/');
148
-
149
- return $prefix === '' || $request->is($prefix, $prefix.'/*');
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([MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class, UpdateCommand::class]);
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: "Can't reach the server. Check that the Laravel app is running." };
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
- return { ok: false, error: body.issues?.[0]?.message ?? body.error ?? "Sign-in failed. Try again." };
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 };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {