create-nevela 0.1.2 → 0.1.4

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.
@@ -3,10 +3,11 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
- use Illuminate\Support\Facades\Http;
7
6
  use Nevela\Laravel\Nevela;
7
+ use Nevela\Laravel\Support\DashboardState;
8
8
  use Nevela\Laravel\Support\DashboardUpdate;
9
- use PharData;
9
+ use Nevela\Laravel\Support\Releases;
10
+ use Nevela\Laravel\Support\TemplateSource;
10
11
  use RecursiveDirectoryIterator;
11
12
  use RecursiveIteratorIterator;
12
13
  use Symfony\Component\Process\PhpExecutableFinder;
@@ -17,7 +18,8 @@ final class UpdateCommand extends Command
17
18
  {
18
19
  protected $signature = 'nevela:update
19
20
  {--check : Show what would change, and change nothing}
20
- {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}';
21
+ {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}
22
+ {--undo : Put the dashboard back as it was before the last update}';
21
23
 
22
24
  protected $description = 'Update Nevela: the package, the generated code and the dashboard';
23
25
 
@@ -26,6 +28,9 @@ final class UpdateCommand extends Command
26
28
 
27
29
  public function handle(): int
28
30
  {
31
+ if ($this->option('undo')) {
32
+ return $this->undo();
33
+ }
29
34
  $check = (bool) $this->option('check');
30
35
 
31
36
  if (! $this->option('skip-package')) {
@@ -37,11 +42,32 @@ final class UpdateCommand extends Command
37
42
 
38
43
  if (! $check) {
39
44
  $this->components->task('Regenerating from the descriptors', fn () => $this->callSilently('nevela:generate') === self::SUCCESS);
45
+ $this->modernizeDevScript();
40
46
  }
41
47
 
42
48
  return $this->updateDashboard($check);
43
49
  }
44
50
 
51
+ /**
52
+ * Apps created before 0.1.3 run both servers with scripts/dev.mjs, which always uses
53
+ * port 8000 and so can end up talking to another program that already has it. Point
54
+ * their `dev` script at `php nevela dev`, which picks a free port, unless they changed it.
55
+ */
56
+ private function modernizeDevScript(): void
57
+ {
58
+ $root = GenerateCommand::projectRoot();
59
+ $file = $root ? $root.DIRECTORY_SEPARATOR.'package.json' : null;
60
+ if (! $file || ! is_file($file)) {
61
+ return;
62
+ }
63
+ $source = (string) file_get_contents($file);
64
+ $updated = preg_replace('/("dev"\s*:\s*)"node scripts\/dev\.mjs"/', '$1"php nevela dev"', $source, 1, $count);
65
+ if ($count === 1) {
66
+ file_put_contents($file, $updated);
67
+ $this->components->twoColumnDetail('package.json: dev', '<fg=blue>node scripts/dev.mjs → php nevela dev</>');
68
+ }
69
+ }
70
+
45
71
  /**
46
72
  * Update the Composer package. Returns an exit code when the rest of the work was
47
73
  * handed to a fresh process, or null to carry on in this one.
@@ -49,7 +75,7 @@ final class UpdateCommand extends Command
49
75
  private function updatePackage(bool $check): ?int
50
76
  {
51
77
  $installed = Nevela::VERSION;
52
- $latest = $this->latestOnPackagist();
78
+ $latest = Releases::latest();
53
79
 
54
80
  if ($folder = $this->installedByPath()) {
55
81
  $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}.");
@@ -105,58 +131,76 @@ final class UpdateCommand extends Command
105
131
 
106
132
  private function updateDashboard(bool $check): int
107
133
  {
108
- $web = config('nevela.web_path');
109
- if (! $web || ! is_dir($web)) {
134
+ $web = $this->webPath();
135
+ if ($web === null) {
110
136
  $this->components->info('No dashboard at nevela.web_path, so there is nothing more to update.');
111
137
 
112
138
  return self::SUCCESS;
113
139
  }
114
- $web = rtrim(str_replace('\\', '/', realpath($web) ?: $web), '/');
115
- $marker = "{$web}/.nevela.json";
116
- $from = is_file($marker) ? (json_decode((string) file_get_contents($marker), true)['template'] ?? self::FIRST_TRACKED) : self::FIRST_TRACKED;
140
+ $state = DashboardState::read($web);
141
+ $from = $state->template ?? self::FIRST_TRACKED;
117
142
  // 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;
143
+ $to = $check ? (Releases::latest() ?? Nevela::VERSION) : Nevela::VERSION;
119
144
 
120
145
  if (version_compare($from, $to, '>=')) {
121
146
  $this->components->info("The dashboard is on the latest template ({$from}).");
147
+ // An app from before fingerprints were kept: record them now, so the next update
148
+ // knows what was changed without having to work it out from a download.
149
+ if (! $check && $state->files === null) {
150
+ try {
151
+ $state->adopt($from, TemplateSource::fetch($from)['files']);
152
+ $state->write($web);
153
+ } catch (Throwable) {
154
+ // Not worth failing an up-to-date app over; the next update can still download it.
155
+ }
156
+ }
122
157
 
123
158
  return self::SUCCESS;
124
159
  }
125
160
 
126
161
  try {
127
- $base = $this->template($from);
128
- $next = $this->template($to);
162
+ $next = TemplateSource::fetch($to)['files'];
163
+ // What the app is on: its recorded fingerprints, or for an older app the old template itself.
164
+ if ($state->files === null) {
165
+ $state->adopt($from, TemplateSource::fetch($from)['files']);
166
+ }
129
167
  } catch (Throwable $e) {
130
- $this->components->error("Couldn't download the dashboard template ({$e->getMessage()}). The dashboard was left as it is; run this again later.");
168
+ $this->components->error("Couldn't get the dashboard template: {$e->getMessage()}. The dashboard was left as it is; run this again later.");
131
169
 
132
170
  return self::FAILURE;
133
171
  }
134
172
 
135
- $paths = array_unique([...array_keys($base), ...array_keys($next)]);
136
- $yours = [];
137
- foreach ($paths as $path) {
138
- $yours[$path] = is_file("{$web}/{$path}") ? (string) file_get_contents("{$web}/{$path}") : null;
139
- }
173
+ $base = $state->files;
174
+ $nextHashes = DashboardUpdate::hashes($next);
175
+ $yours = DashboardState::fingerprints($web, array_unique([...array_keys($base), ...array_keys($next)]));
140
176
 
141
177
  // package.json is merged, not replaced: it has your app's name and your own dependencies.
142
178
  $packages = null;
143
- if (isset($base['package.json'], $next['package.json'], $yours['package.json'])) {
144
- $packages = DashboardUpdate::mergePackageJson($yours['package.json'], $base['package.json'], $next['package.json']);
179
+ if (isset($next['package.json']) && is_file("{$web}/package.json")) {
180
+ $packages = DashboardUpdate::mergePackageJson((string) file_get_contents("{$web}/package.json"), $state->packageJson(), $next['package.json']);
145
181
  }
146
- unset($base['package.json'], $next['package.json']);
182
+ unset($base['package.json'], $nextHashes['package.json'], $yours['package.json']);
147
183
 
148
- $plan = DashboardUpdate::plan($base, $next, $yours);
184
+ $plan = DashboardUpdate::plan($base, $nextHashes, $yours);
149
185
  $this->components->info(($check ? 'Dashboard: what would change, ' : 'Dashboard: ')."template {$from} → {$to}");
150
186
 
151
- $counts = [DashboardUpdate::UPDATE => 0, DashboardUpdate::ADD => 0, DashboardUpdate::CONFLICT => 0, DashboardUpdate::REMOVED => 0];
187
+ $done = [DashboardUpdate::UPDATE => [], DashboardUpdate::ADD => [], DashboardUpdate::CONFLICT => [], DashboardUpdate::REMOVED => []];
188
+ $backup = '.nevela/backups/'.gmdate('Ymd-His')."-{$from}-to-{$to}";
189
+ $incoming = ".nevela/incoming/{$to}";
190
+
152
191
  foreach ($plan as $path => $action) {
153
- $counts[$action]++;
154
- if (! $check && ($action === DashboardUpdate::UPDATE || $action === DashboardUpdate::ADD)) {
155
- $target = "{$web}/{$path}";
156
- if (! is_dir(dirname($target))) {
157
- mkdir(dirname($target), 0775, true);
192
+ $done[$action][] = $path;
193
+ if (! $check) {
194
+ if ($action === DashboardUpdate::UPDATE) {
195
+ // Untouched by you, so identical to the old template. Kept anyway: a copy costs nothing.
196
+ $this->put("{$web}/{$backup}/{$path}", (string) file_get_contents("{$web}/{$path}"));
197
+ $this->put("{$web}/{$path}", $next[$path]);
198
+ } elseif ($action === DashboardUpdate::ADD) {
199
+ $this->put("{$web}/{$path}", $next[$path]);
200
+ } elseif ($action === DashboardUpdate::CONFLICT) {
201
+ // Yours stays. The new version is put where you can compare the two.
202
+ $this->put("{$web}/{$incoming}/{$path}", $next[$path]);
158
203
  }
159
- file_put_contents($target, $next[$path]);
160
204
  }
161
205
  $this->components->twoColumnDetail($path, match ($action) {
162
206
  DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
@@ -166,107 +210,137 @@ final class UpdateCommand extends Command
166
210
  });
167
211
  }
168
212
 
169
- if ($packages && $packages['changed']) {
170
- foreach ($packages['changed'] as $name => $change) {
171
- $this->components->twoColumnDetail("package.json: {$name}", '<fg=blue>'.$change.'</>');
172
- }
173
- if (! $check) {
174
- file_put_contents("{$web}/package.json", $packages['json']);
175
- }
213
+ foreach ($packages['changed'] ?? [] as $name => $change) {
214
+ $this->components->twoColumnDetail("package.json: {$name}", '<fg=blue>'.$change.'</>');
176
215
  }
177
216
  foreach ($packages['kept'] ?? [] as $name => $versions) {
178
217
  $this->components->twoColumnDetail("package.json: {$name}", "<fg=yellow>kept yours, {$versions}</>");
179
218
  }
180
-
181
219
  if ($plan === [] && ! ($packages['changed'] ?? [])) {
182
220
  $this->line(' Nothing in the dashboard needed changing.');
183
221
  }
184
222
  $this->newLine();
185
223
 
186
224
  if (! $check) {
187
- file_put_contents($marker, json_encode(['template' => $to], JSON_PRETTY_PRINT)."\n");
225
+ $touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || ($packages['changed'] ?? []) !== [];
226
+ if ($packages && $packages['changed']) {
227
+ $this->put("{$web}/{$backup}/package.json", (string) file_get_contents("{$web}/package.json"));
228
+ file_put_contents("{$web}/package.json", $packages['json']);
229
+ }
230
+ if ($touched) {
231
+ // The record as it was, so `--undo` can put that back too.
232
+ $this->put("{$web}/{$backup}/.nevela.json", (string) @file_get_contents(DashboardState::path($web)) ?: '{}');
233
+ }
234
+ // .nevela/ holds copies, not source: it keeps itself out of git.
235
+ if (is_dir("{$web}/.nevela") && ! is_file("{$web}/.nevela/.gitignore")) {
236
+ file_put_contents("{$web}/.nevela/.gitignore", "*\n");
237
+ }
238
+
239
+ $state->adopt($to, $next);
240
+ $state->history[] = array_filter([
241
+ 'at' => gmdate('c'),
242
+ 'from' => $from,
243
+ 'to' => $to,
244
+ 'updated' => $done[DashboardUpdate::UPDATE],
245
+ 'added' => $done[DashboardUpdate::ADD],
246
+ 'kept' => $done[DashboardUpdate::CONFLICT],
247
+ 'removed' => $done[DashboardUpdate::REMOVED],
248
+ 'dependencies' => $packages['changed'] ?? [],
249
+ 'backup' => $touched ? $backup : null,
250
+ ]);
251
+ $state->write($web);
252
+
253
+ if ($touched) {
254
+ $this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela update --undo</>");
255
+ }
188
256
  }
189
- if ($counts[DashboardUpdate::CONFLICT] > 0) {
190
- $this->components->warn("{$counts[DashboardUpdate::CONFLICT]} file(s) changed in the template and in your app. Yours were kept. To see the template's version of one: https://github.com/MarkColeMukisa/nevela/tree/v{$to}/apps/web");
257
+ if ($done[DashboardUpdate::CONFLICT] !== []) {
258
+ $count = count($done[DashboardUpdate::CONFLICT]);
259
+ $this->components->warn("{$count} file(s) changed in the template and in your app. Yours were kept.".($check ? '' : " The template's new versions are in {$this->relative($web)}/{$incoming} for you to compare."));
191
260
  }
192
- if ($counts[DashboardUpdate::REMOVED] > 0) {
193
- $this->line(" {$counts[DashboardUpdate::REMOVED]} file(s) are no longer part of the template. They were left in place; delete them if you don't use them.");
261
+ if ($done[DashboardUpdate::REMOVED] !== []) {
262
+ $this->line(' '.count($done[DashboardUpdate::REMOVED])." file(s) are no longer part of the template. They were left in place; delete them if you don't use them.");
194
263
  }
195
264
  if (! $check && ($packages['changed'] ?? [])) {
196
- $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && pnpm install</> (or npm install).');
265
+ $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && '.DevCommand::packageManager($web).' install</>');
197
266
  }
198
267
 
199
268
  return self::SUCCESS;
200
269
  }
201
270
 
202
- /**
203
- * The dashboard as it shipped in a version: path => contents.
204
- *
205
- * It comes from the create-nevela package on npm, which carries the dashboard and is
206
- * released with the same version number as this package.
207
- *
208
- * @return array<string, string>
209
- */
210
- private function template(string $version): array
271
+ /** Put the dashboard back as it was before the last update that changed it. */
272
+ private function undo(): int
211
273
  {
212
- $dir = rtrim(sys_get_temp_dir(), '/\\').DIRECTORY_SEPARATOR.'nevela-template-'.$version.'-'.bin2hex(random_bytes(4));
213
- mkdir($dir, 0775, true);
214
- $archive = $dir.DIRECTORY_SEPARATOR.'template.tgz';
274
+ $web = $this->webPath();
275
+ if ($web === null) {
276
+ $this->components->error('No dashboard at nevela.web_path.');
215
277
 
216
- try {
217
- // NEVELA_TEMPLATE_DIR: a folder of create-nevela-<version>.tgz files to use instead
218
- // of npm. For working on Nevela itself, and for updating without a connection.
219
- $local = rtrim((string) env('NEVELA_TEMPLATE_DIR', ''), '/\\');
220
- if ($local !== '' && is_file("{$local}/create-nevela-{$version}.tgz")) {
221
- copy("{$local}/create-nevela-{$version}.tgz", $archive);
222
- } else {
223
- $response = Http::timeout(60)->get("https://registry.npmjs.org/create-nevela/-/create-nevela-{$version}.tgz");
224
- if (! $response->successful()) {
225
- throw new \RuntimeException("create-nevela {$version} is not on npm (HTTP {$response->status()})");
226
- }
227
- file_put_contents($archive, $response->body());
278
+ return self::FAILURE;
279
+ }
280
+ $state = DashboardState::read($web);
281
+ $last = null;
282
+ foreach (array_reverse($state->history) as $entry) {
283
+ if (isset($entry['backup']) && is_dir("{$web}/{$entry['backup']}")) {
284
+ $last = $entry;
285
+ break;
228
286
  }
229
- (new PharData($archive))->extractTo($dir, null, true);
287
+ }
288
+ if ($last === null) {
289
+ $this->components->info('There is no update to undo: no backup was found.');
230
290
 
231
- $root = $dir.DIRECTORY_SEPARATOR.'package'.DIRECTORY_SEPARATOR.'template'.DIRECTORY_SEPARATOR.'web';
232
- if (! is_dir($root)) {
233
- throw new \RuntimeException("create-nevela {$version} has no dashboard template in it");
291
+ return self::SUCCESS;
292
+ }
293
+
294
+ $dir = "{$web}/{$last['backup']}";
295
+ $restored = 0;
296
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
297
+ $path = str_replace('\\', '/', substr($file->getPathname(), strlen($dir) + 1));
298
+ if ($path === '.nevela.json') {
299
+ continue;
234
300
  }
235
- $files = [];
236
- foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
237
- $path = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
238
- // npm can't carry .gitignore files, so the template holds them as _gitignore.
239
- $path = preg_replace('#(^|/)_gitignore$#', '$1.gitignore', $path);
240
- $files[$path] = (string) file_get_contents($file->getPathname());
301
+ $this->put("{$web}/{$path}", (string) file_get_contents($file->getPathname()));
302
+ $this->components->twoColumnDetail($path, '<fg=blue>restored</>');
303
+ $restored++;
304
+ }
305
+ // Files the update added are taken out again, unless you have changed them since.
306
+ foreach ($last['added'] ?? [] as $path) {
307
+ $recorded = $state->files[$path] ?? null;
308
+ if (is_file("{$web}/{$path}") && $recorded !== null && DashboardUpdate::hash((string) file_get_contents("{$web}/{$path}")) === $recorded) {
309
+ unlink("{$web}/{$path}");
310
+ $this->components->twoColumnDetail($path, '<fg=gray>removed (the update had added it)</>');
311
+ } elseif (is_file("{$web}/{$path}")) {
312
+ $this->components->twoColumnDetail($path, '<fg=yellow>kept: you changed it after the update</>');
241
313
  }
242
-
243
- return $files;
244
- } finally {
245
- $this->removeDirectory($dir);
246
314
  }
315
+ // The record goes back too, so the same update can be run again later.
316
+ if (is_file("{$dir}/.nevela.json")) {
317
+ copy("{$dir}/.nevela.json", DashboardState::path($web));
318
+ }
319
+
320
+ $this->newLine();
321
+ $this->components->info("Put back {$restored} file(s). The dashboard is as it was before the update from {$last['from']} to {$last['to']}.");
322
+ $this->line(' The nevela/laravel package was not changed by this. To go back a version there too: <fg=cyan>composer require nevela/laravel:'.$last['from'].'</>');
323
+
324
+ return self::SUCCESS;
247
325
  }
248
326
 
249
- /** The newest stable release on Packagist, or null when it can't be reached. */
250
- private function latestOnPackagist(): ?string
327
+ /** The dashboard's folder, with forward slashes, or null when there isn't one. */
328
+ private function webPath(): ?string
251
329
  {
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
- }
330
+ $web = config('nevela.web_path');
331
+ if (! $web || ! is_dir($web)) {
332
+ return null;
266
333
  }
267
- usort($versions, 'version_compare');
268
334
 
269
- return $latest = ($versions === [] ? null : end($versions));
335
+ return rtrim(str_replace('\\', '/', realpath($web) ?: $web), '/');
336
+ }
337
+
338
+ private function put(string $file, string $contents): void
339
+ {
340
+ if (! is_dir(dirname($file))) {
341
+ mkdir(dirname($file), 0775, true);
342
+ }
343
+ file_put_contents($file, $contents);
270
344
  }
271
345
 
272
346
  /** The folder Composer installs the package from, when it isn't coming from Packagist. */
@@ -282,23 +356,16 @@ final class UpdateCommand extends Command
282
356
  return null;
283
357
  }
284
358
 
359
+ /** A path as someone at the top of the project would type it: apps/web/… */
285
360
  private function relative(string $path): string
286
361
  {
287
- $base = rtrim(str_replace('\\', '/', base_path()), '/');
288
- $parent = dirname($base);
362
+ $root = GenerateCommand::projectRoot();
363
+ $root = $root === null ? null : rtrim(str_replace('\\', '/', $root), '/');
364
+ if ($root !== null && str_starts_with($path, $root.'/')) {
365
+ return substr($path, strlen($root) + 1);
366
+ }
367
+ $parent = dirname(rtrim(str_replace('\\', '/', base_path()), '/'));
289
368
 
290
369
  return str_starts_with($path, $parent.'/') ? '../'.substr($path, strlen($parent) + 1) : $path;
291
370
  }
292
-
293
- private function removeDirectory(string $dir): void
294
- {
295
- if (! is_dir($dir)) {
296
- return;
297
- }
298
- $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
299
- foreach ($items as $item) {
300
- $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
301
- }
302
- @rmdir($dir);
303
- }
304
371
  }
@@ -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