create-nevela 0.1.5 → 0.3.0

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.
Files changed (38) hide show
  1. package/README.md +17 -6
  2. package/index.mjs +77 -16
  3. package/package.json +2 -1
  4. package/project.mjs +20 -4
  5. package/selfupdate.mjs +13 -3
  6. package/template/laravel/README.md +1 -1
  7. package/template/laravel/composer.json +61 -59
  8. package/template/laravel/config/nevela.php +70 -0
  9. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  10. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  11. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  12. package/template/laravel/src/Console/SeedCommand.php +186 -124
  13. package/template/laravel/src/Console/StatusCommand.php +2 -2
  14. package/template/laravel/src/Console/UpdateCommand.php +76 -9
  15. package/template/laravel/src/Generator/ResourceGenerator.php +154 -21
  16. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  17. package/template/laravel/src/Http/UploadController.php +147 -0
  18. package/template/laravel/src/Media/FileTypes.php +97 -0
  19. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  20. package/template/laravel/src/Media/Profile.php +94 -0
  21. package/template/laravel/src/Media/UploadRejected.php +8 -0
  22. package/template/laravel/src/Media/Uploads.php +325 -0
  23. package/template/laravel/src/Models/Upload.php +46 -0
  24. package/template/laravel/src/Nevela.php +20 -1
  25. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  26. package/template/laravel/src/Rules/UploadKey.php +27 -0
  27. package/template/laravel/src/Support/Descriptor.php +3 -3
  28. package/template/laravel/src/Support/Fake.php +194 -171
  29. package/template/laravel/src/Support/Field.php +108 -12
  30. package/template/laravel/src/Support/TemplateSource.php +1 -1
  31. package/template/web/app/api/[...path]/route.ts +87 -0
  32. package/template/web/app/dashboard/actions.ts +26 -9
  33. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  34. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  35. package/template/web/lib/files.ts +21 -0
  36. package/template/web/package.json +1 -1
  37. package/template.mjs +2 -2
  38. package/tool.mjs +58 -0
@@ -1,124 +1,186 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Console;
4
-
5
- use Illuminate\Console\Command;
6
- use Illuminate\Database\UniqueConstraintViolationException;
7
- use Illuminate\Support\Facades\DB;
8
- use Illuminate\Support\Str;
9
- use InvalidArgumentException;
10
- use Nevela\Laravel\Nevela;
11
- use Nevela\Laravel\Support\Descriptor;
12
- use Nevela\Laravel\Support\Fake;
13
- use Symfony\Component\Console\Helper\ProgressBar;
14
- use Throwable;
15
-
16
- final class SeedCommand extends Command
17
- {
18
- protected $signature = 'nevela:seed
19
- {name : The resource to fill, e.g. Product}
20
- {--count=1000 : How many records to create}
21
- {--chunk=500 : Rows per insert statement}
22
- {--fresh : Delete the resource\'s existing records first}';
23
-
24
- protected $description = 'Fill a resource with plausible records, and say how long it took';
25
-
26
- private const MAX_COUNT = 5_000_000;
27
-
28
- /** 400 blocks of MAX_COUNT stay under 2^31, so a unique integer column can hold the numbers. */
29
- private const BLOCKS = 400;
30
-
31
- private const ATTEMPTS = 5;
32
-
33
- public function handle(): int
34
- {
35
- try {
36
- $resource = Nevela::resource(Str::studly($this->argument('name')));
37
- } catch (InvalidArgumentException $e) {
38
- $this->components->error($e->getMessage());
39
-
40
- return self::FAILURE;
41
- }
42
-
43
- $count = filter_var($this->option('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => self::MAX_COUNT]]);
44
- $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
45
- if ($count === false || $chunk === false) {
46
- $this->components->error('--count must be between 1 and 5,000,000, and --chunk between 1 and 5,000.');
47
-
48
- return self::FAILURE;
49
- }
50
-
51
- // SQLite allows 32,766 bound values per statement; stay well inside it on wide tables.
52
- $chunk = min($chunk, max(1, intdiv(30_000, count($resource->fields) + 3)));
53
- $fresh = (bool) $this->option('fresh');
54
-
55
- $existing = $fresh ? 0 : (int) DB::table($resource->table)->count();
56
- foreach ($resource->fields as $field) {
57
- $capacity = Fake::capacity($field);
58
- if ($capacity !== null && $existing + $count > $capacity) {
59
- $room = number_format(max(0, $capacity - $existing));
60
- $this->components->error("\"{$field->name}\" is unique and can only hold ".number_format($capacity)." different values, so there is room for {$room} more {$resource->pluralLabel}, not ".number_format($count).'.');
61
-
62
- return self::FAILURE;
63
- }
64
- }
65
-
66
- $started = microtime(true);
67
- $removed = 0;
68
- $bar = $this->output->createProgressBar($count);
69
- try {
70
- for ($attempt = 1; ; $attempt++) {
71
- // Unique fields are numbered from a block of MAX_COUNT picked per run (a run is at
72
- // most that long). Two runs only meet if they pick the same block; then the insert
73
- // fails, the transaction rolls back and another block is picked.
74
- $offset = $fresh && $attempt === 1 ? 0 : mt_rand(0, self::BLOCKS - 1) * self::MAX_COUNT;
75
- $bar->start();
76
- try {
77
- $removed = DB::transaction(fn () => $this->fill($resource, $count, $chunk, $offset, $fresh, $bar));
78
- break;
79
- } catch (UniqueConstraintViolationException $e) {
80
- if ($attempt === self::ATTEMPTS) {
81
- throw $e;
82
- }
83
- }
84
- }
85
- $bar->finish();
86
- $this->newLine(2);
87
- } catch (Throwable $e) {
88
- $this->newLine(2);
89
- $this->components->error("Seeding {$resource->pluralLabel} failed, and the table is as it was: ".Str::limit($e->getMessage(), 300));
90
-
91
- return self::FAILURE;
92
- }
93
- if ($fresh) {
94
- $this->components->info('Removed '.number_format($removed)." existing {$resource->pluralLabel}.");
95
- }
96
-
97
- $seconds = microtime(true) - $started;
98
- $time = $seconds < 1 ? round($seconds * 1000).' ms' : number_format($seconds, 2).' s';
99
- $rate = number_format($count / max($seconds, 0.001));
100
- $this->components->info('Seeded '.number_format($count)." {$resource->pluralLabel} in {$time} ({$rate} rows/s).");
101
- $this->components->twoColumnDetail("{$resource->pluralLabel} in the database", number_format(DB::table($resource->table)->count()));
102
-
103
- return self::SUCCESS;
104
- }
105
-
106
- /** Runs inside the transaction: the optional delete and every insert succeed or fail together. */
107
- private function fill(Descriptor $resource, int $count, int $chunk, int $offset, bool $fresh, ProgressBar $bar): int
108
- {
109
- $removed = $fresh ? DB::table($resource->table)->delete() : 0;
110
- $now = time();
111
- for ($done = 0; $done < $count; $done += $chunk) {
112
- $rows = [];
113
- for ($i = $done; $i < min($count, $done + $chunk); $i++) {
114
- // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
115
- $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
116
- $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1) + ['created_at' => $created, 'updated_at' => $created];
117
- }
118
- DB::table($resource->table)->insert($rows);
119
- $bar->advance(count($rows));
120
- }
121
-
122
- return $removed;
123
- }
124
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Database\UniqueConstraintViolationException;
7
+ use Illuminate\Support\Facades\DB;
8
+ use Illuminate\Support\Str;
9
+ use InvalidArgumentException;
10
+ use Nevela\Laravel\Media\Uploads;
11
+ use Nevela\Laravel\Nevela;
12
+ use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Fake;
14
+ use Symfony\Component\Console\Helper\ProgressBar;
15
+ use Throwable;
16
+
17
+ final class SeedCommand extends Command
18
+ {
19
+ protected $signature = 'nevela:seed
20
+ {name : The resource to fill, e.g. Product}
21
+ {--count=1000 : How many records to create}
22
+ {--chunk=500 : Rows per insert statement}
23
+ {--fresh : Delete the resource\'s existing records first}';
24
+
25
+ protected $description = 'Fill a resource with plausible records, and say how long it took';
26
+
27
+ private const MAX_COUNT = 5_000_000;
28
+
29
+ /** 400 blocks of MAX_COUNT stay under 2^31, so a unique integer column can hold the numbers. */
30
+ private const BLOCKS = 400;
31
+
32
+ private const ATTEMPTS = 5;
33
+
34
+ public function handle(): int
35
+ {
36
+ try {
37
+ $resource = Nevela::resource(Str::studly($this->argument('name')));
38
+ } catch (InvalidArgumentException $e) {
39
+ $this->components->error($e->getMessage());
40
+
41
+ return self::FAILURE;
42
+ }
43
+
44
+ $count = filter_var($this->option('count'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => self::MAX_COUNT]]);
45
+ $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
46
+ if ($count === false || $chunk === false) {
47
+ $this->components->error('--count must be between 1 and 5,000,000, and --chunk between 1 and 5,000.');
48
+
49
+ return self::FAILURE;
50
+ }
51
+
52
+ // SQLite allows 32,766 bound values per statement; stay well inside it on wide tables.
53
+ $chunk = min($chunk, max(1, intdiv(30_000, count($resource->fields) + 3)));
54
+ $fresh = (bool) $this->option('fresh');
55
+
56
+ $existing = $fresh ? 0 : (int) DB::table($resource->table)->count();
57
+ foreach ($resource->fields as $field) {
58
+ $capacity = Fake::capacity($field);
59
+ if ($capacity !== null && $existing + $count > $capacity) {
60
+ $room = number_format(max(0, $capacity - $existing));
61
+ $this->components->error("\"{$field->name}\" is unique and can only hold ".number_format($capacity)." different values, so there is room for {$room} more {$resource->pluralLabel}, not ".number_format($count).'.');
62
+
63
+ return self::FAILURE;
64
+ }
65
+ }
66
+
67
+ // --fresh deletes first, and a record others are required to belong to can't be deleted.
68
+ // Said here, before any placeholder pictures are made for a run that can't happen.
69
+ if ($fresh) {
70
+ foreach (Nevela::all() as $other) {
71
+ foreach ($other->fields as $field) {
72
+ if ($field->kind === 'belongsTo' && $field->target === $resource->name && $field->required && $other->name !== $resource->name
73
+ && ($children = (int) DB::table($other->table)->count()) > 0) {
74
+ $this->components->error(number_format($children)." {$other->pluralLabel} belong to the {$resource->pluralLabel} that --fresh would delete. Remove them first, or seed without --fresh.");
75
+
76
+ return self::FAILURE;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ $started = microtime(true);
83
+ try {
84
+ $pools = $this->pools($resource);
85
+ } catch (InvalidArgumentException $e) {
86
+ $this->components->error($e->getMessage());
87
+
88
+ return self::FAILURE;
89
+ }
90
+ $removed = 0;
91
+ $bar = $this->output->createProgressBar($count);
92
+ try {
93
+ for ($attempt = 1; ; $attempt++) {
94
+ // Unique fields are numbered from a block of MAX_COUNT picked per run (a run is at
95
+ // most that long). Two runs only meet if they pick the same block; then the insert
96
+ // fails, the transaction rolls back and another block is picked.
97
+ $offset = $fresh && $attempt === 1 ? 0 : mt_rand(0, self::BLOCKS - 1) * self::MAX_COUNT;
98
+ $bar->start();
99
+ try {
100
+ $removed = DB::transaction(fn () => $this->fill($resource, $count, $chunk, $offset, $fresh, $bar, $pools));
101
+ break;
102
+ } catch (UniqueConstraintViolationException $e) {
103
+ if ($attempt === self::ATTEMPTS) {
104
+ throw $e;
105
+ }
106
+ }
107
+ }
108
+ $bar->finish();
109
+ $this->newLine(2);
110
+ } catch (Throwable $e) {
111
+ $this->newLine(2);
112
+ $this->components->error("Seeding {$resource->pluralLabel} failed, and the table is as it was: ".Str::limit($e->getMessage(), 300));
113
+
114
+ return self::FAILURE;
115
+ }
116
+ if ($fresh) {
117
+ $this->components->info('Removed '.number_format($removed)." existing {$resource->pluralLabel}.");
118
+ }
119
+
120
+ $seconds = microtime(true) - $started;
121
+ $time = $seconds < 1 ? round($seconds * 1000).' ms' : number_format($seconds, 2).' s';
122
+ $rate = number_format($count / max($seconds, 0.001));
123
+ $this->components->info('Seeded '.number_format($count)." {$resource->pluralLabel} in {$time} ({$rate} rows/s).");
124
+ $this->components->twoColumnDetail("{$resource->pluralLabel} in the database", number_format(DB::table($resource->table)->count()));
125
+
126
+ return self::SUCCESS;
127
+ }
128
+
129
+ /**
130
+ * What a relation or an image field can be filled with: the records that exist to point
131
+ * at, and a handful of pictures made for the purpose.
132
+ *
133
+ * @return array<string, list<string>>
134
+ *
135
+ * @throws InvalidArgumentException when a required field has nothing to draw from
136
+ */
137
+ private function pools(Descriptor $resource): array
138
+ {
139
+ $pools = [];
140
+ foreach ($resource->fields as $field) {
141
+ if ($field->kind === 'belongsTo') {
142
+ $target = $field->target === $resource->name ? $resource : Nevela::resource((string) $field->target);
143
+ $ids = DB::table($target->table)->inRandomOrder()->limit(1000)->pluck('id')->map(fn ($id) => (string) $id)->all();
144
+ if ($field->unique) {
145
+ throw new InvalidArgumentException("\"{$field->name}\" is unique, so each {$target->label} can be used once. The seeder doesn't fill one-to-one relations; add those records yourself.");
146
+ }
147
+ if ($ids === [] && $field->required && $target->name !== $resource->name) {
148
+ throw new InvalidArgumentException("Every {$resource->label} needs a {$target->label}, and there are none yet. Seed them first: php artisan nevela:seed {$target->name}");
149
+ }
150
+ $pools[$field->name] = $ids;
151
+ } elseif ($field->kind === 'file') {
152
+ $pools[$field->name] = $field->isImage() ? Uploads::placeholders($resource, $field) : [];
153
+ if ($pools[$field->name] === [] && $field->required) {
154
+ throw new InvalidArgumentException($field->isImage()
155
+ ? "\"{$field->name}\" is a required image, and PHP's GD extension isn't installed to make placeholder pictures with."
156
+ : "\"{$field->name}\" is a required file, which the seeder can't make up. Make it optional ({$field->name}:file(…)?) or add records yourself.");
157
+ }
158
+ }
159
+ }
160
+
161
+ return $pools;
162
+ }
163
+
164
+ /**
165
+ * Runs inside the transaction: the optional delete and every insert succeed or fail together.
166
+ *
167
+ * @param array<string, list<string>> $pools
168
+ */
169
+ private function fill(Descriptor $resource, int $count, int $chunk, int $offset, bool $fresh, ProgressBar $bar, array $pools = []): int
170
+ {
171
+ $removed = $fresh ? DB::table($resource->table)->delete() : 0;
172
+ $now = time();
173
+ for ($done = 0; $done < $count; $done += $chunk) {
174
+ $rows = [];
175
+ for ($i = $done; $i < min($count, $done + $chunk); $i++) {
176
+ // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
177
+ $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
178
+ $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
179
+ }
180
+ DB::table($resource->table)->insert($rows);
181
+ $bar->advance(count($rows));
182
+ }
183
+
184
+ return $removed;
185
+ }
186
+ }
@@ -30,7 +30,7 @@ final class StatusCommand extends Command
30
30
  $latest = Releases::latest();
31
31
  $row('Nevela', Nevela::VERSION.match (true) {
32
32
  $latest === null => ' <fg=gray>(couldn\'t check for a newer one)</>',
33
- version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela update)</>",
33
+ version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela upgrade)</>",
34
34
  default => ' <fg=green>(latest)</>',
35
35
  });
36
36
  $row('Laravel / PHP', app()->version().' / '.PHP_VERSION);
@@ -99,7 +99,7 @@ final class StatusCommand extends Command
99
99
  // What this app has made its own. An update leaves every one of these alone.
100
100
  $changes = $state->yourChanges($web);
101
101
  if ($changes === null) {
102
- $row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela update records them</>');
102
+ $row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela upgrade records them</>');
103
103
  } else {
104
104
  $count = count($changes['changed']) + count($changes['deleted']);
105
105
  $row('Your dashboard changes', $count === 0 ? 'none' : "{$count} file(s), kept on every update");
@@ -16,12 +16,18 @@ use Throwable;
16
16
 
17
17
  final class UpdateCommand extends Command
18
18
  {
19
- protected $signature = 'nevela:update
19
+ protected $signature = 'nevela:upgrade
20
20
  {--check : Show what would change, and change nothing}
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
+ {--skip-package : Leave the nevela/laravel package as it is; upgrade only the dashboard}
22
+ {--undo : Put the dashboard back as it was before the last upgrade}';
23
23
 
24
- protected $description = 'Update Nevela: the package, the generated code and the dashboard';
24
+ /**
25
+ * The name this command had before 0.3.0, and still answers to. As in Grit, "update"
26
+ * is now what the nevela command does to itself, and "upgrade" is what it does to an app.
27
+ */
28
+ protected $aliases = ['nevela:update'];
29
+
30
+ protected $description = 'Bring this app to the latest Nevela: the package, the generated code and the dashboard';
25
31
 
26
32
  /** Apps created before the dashboard recorded its version all came from this template. */
27
33
  private const FIRST_TRACKED = '0.1.1';
@@ -45,7 +51,48 @@ final class UpdateCommand extends Command
45
51
  $this->modernizeDevScript();
46
52
  }
47
53
 
48
- return $this->updateDashboard($check);
54
+ $status = $this->updateDashboard($check);
55
+
56
+ // A new version may bring a table of its own (uploads did). Say so, don't run it:
57
+ // changing a database is the developer's call.
58
+ if (! $check && ($pending = $this->pendingMigrations()) > 0) {
59
+ $this->newLine();
60
+ $this->components->warn("{$pending} migration(s) have not been run. Run them before using the app: php nevela migrate");
61
+ }
62
+
63
+ return $status;
64
+ }
65
+
66
+ /** What the app's composer.json asks for, or an empty string when it can't be read. */
67
+ public static function rootConstraint(string $composerJson): string
68
+ {
69
+ $manifest = json_decode($composerJson, true);
70
+
71
+ return is_array($manifest) && is_string($manifest['require']['nevela/laravel'] ?? null) ? trim($manifest['require']['nevela/laravel']) : '';
72
+ }
73
+
74
+ /**
75
+ * Whether a constraint is the kind the installer writes ("^0.1", "^0.1.3") and nothing
76
+ * more: the only kind an update moves on its own.
77
+ */
78
+ public static function isPlainCaret(string $constraint): bool
79
+ {
80
+ return preg_match('/^\^\d+\.\d+(\.\d+)?$/', $constraint) === 1;
81
+ }
82
+
83
+ private function pendingMigrations(): int
84
+ {
85
+ try {
86
+ $migrator = app('migrator');
87
+ if (! $migrator->repositoryExists()) {
88
+ return 0;
89
+ }
90
+ $files = $migrator->getMigrationFiles([...$migrator->paths(), database_path('migrations')]);
91
+
92
+ return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
93
+ } catch (\Throwable) {
94
+ return 0;
95
+ }
49
96
  }
50
97
 
51
98
  /**
@@ -115,15 +162,35 @@ final class UpdateCommand extends Command
115
162
  $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
116
163
  $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
117
164
  if ($now === $installed) {
118
- $this->components->warn("Composer kept {$installed}: the constraint in composer.json doesn't allow {$latest}. Set \"nevela/laravel\" to \"^".implode('.', array_slice(explode('.', $latest), 0, 2)).'" there and run this again.');
165
+ // composer.json's range doesn't reach the new version: below 1.0, "^0.1" stops
166
+ // at 0.1.x. Updating is asking for the newest, so ask Composer for the range
167
+ // that has it. `require` rewrites composer.json and installs in one step.
168
+ $range = '^'.implode('.', array_slice(explode('.', $latest), 0, 2));
169
+ $current = self::rootConstraint((string) @file_get_contents(base_path('composer.json')));
170
+ if (! self::isPlainCaret($current)) {
171
+ // A pin, a branch or a hand-written range is somebody's decision. It is left
172
+ // as it is, and the update stops here.
173
+ $this->components->warn("Composer kept {$installed}: composer.json asks for \"{$current}\", which doesn't allow {$latest}. That looks deliberate, so it was left alone. To move, set \"nevela/laravel\" to \"{$range}\" there and run this again.");
174
+
175
+ return null;
176
+ }
177
+ $this->components->info("composer.json asks for \"{$current}\", which doesn't reach {$latest}. Moving \"nevela/laravel\" to \"{$range}\".");
178
+ $require = new Process(['composer', 'require', "nevela/laravel:{$range}", '--no-interaction', '--no-progress', '--update-with-dependencies'], base_path(), null, null, null);
179
+ $require->run(fn ($type, $buffer) => $this->output->write($buffer));
119
180
 
120
- return null;
181
+ $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
182
+ $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
183
+ if (! $require->isSuccessful() || $now === $installed) {
184
+ $this->components->warn("Composer kept {$installed}. Set \"nevela/laravel\" to \"{$range}\" in composer.json, see what Composer says about it, and run this again.");
185
+
186
+ return null;
187
+ }
121
188
  }
122
189
 
123
190
  // The package's own files have just been replaced. Finish in a new process, so the
124
191
  // rest runs on the new code instead of a mix of old and new.
125
192
  $php = (new PhpExecutableFinder)->find(false) ?: 'php';
126
- $rest = new Process([$php, 'artisan', 'nevela:update', '--skip-package'], base_path(), null, null, null);
193
+ $rest = new Process([$php, 'artisan', 'nevela:upgrade', '--skip-package'], base_path(), null, null, null);
127
194
  $rest->run(fn ($type, $buffer) => $this->output->write($buffer));
128
195
 
129
196
  return $rest->getExitCode() ?? self::FAILURE;
@@ -251,7 +318,7 @@ final class UpdateCommand extends Command
251
318
  $state->write($web);
252
319
 
253
320
  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</>");
321
+ $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 upgrade --undo</>");
255
322
  }
256
323
  }
257
324
  if ($done[DashboardUpdate::CONFLICT] !== []) {