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.
@@ -0,0 +1,303 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Http;
7
+ use Nevela\Laravel\Nevela;
8
+ use Nevela\Laravel\Support\DashboardUpdate;
9
+ use Nevela\Laravel\Support\Releases;
10
+ use PharData;
11
+ use RecursiveDirectoryIterator;
12
+ use RecursiveIteratorIterator;
13
+ use Symfony\Component\Process\PhpExecutableFinder;
14
+ use Symfony\Component\Process\Process;
15
+ use Throwable;
16
+
17
+ final class UpdateCommand extends Command
18
+ {
19
+ protected $signature = 'nevela:update
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
+
23
+ protected $description = 'Update Nevela: the package, the generated code and the dashboard';
24
+
25
+ /** Apps created before the dashboard recorded its version all came from this template. */
26
+ private const FIRST_TRACKED = '0.1.1';
27
+
28
+ public function handle(): int
29
+ {
30
+ $check = (bool) $this->option('check');
31
+
32
+ if (! $this->option('skip-package')) {
33
+ $status = $this->updatePackage($check);
34
+ if ($status !== null) {
35
+ return $status;
36
+ }
37
+ }
38
+
39
+ if (! $check) {
40
+ $this->components->task('Regenerating from the descriptors', fn () => $this->callSilently('nevela:generate') === self::SUCCESS);
41
+ $this->modernizeDevScript();
42
+ }
43
+
44
+ return $this->updateDashboard($check);
45
+ }
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
+
67
+ /**
68
+ * Update the Composer package. Returns an exit code when the rest of the work was
69
+ * handed to a fresh process, or null to carry on in this one.
70
+ */
71
+ private function updatePackage(bool $check): ?int
72
+ {
73
+ $installed = Nevela::VERSION;
74
+ $latest = Releases::latest();
75
+
76
+ if ($folder = $this->installedByPath()) {
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}.");
78
+ $this->line(' To move to Packagist: remove the "nevela" entry under "repositories" in composer.json, then run <fg=cyan>composer require nevela/laravel</>.');
79
+ $this->newLine();
80
+
81
+ return null;
82
+ }
83
+
84
+ if ($latest === null) {
85
+ $this->components->warn("Couldn't reach Packagist to look for a newer version. Carrying on with {$installed}.");
86
+
87
+ return null;
88
+ }
89
+ if (version_compare($latest, $installed, '<=')) {
90
+ $this->components->info("nevela/laravel {$installed} is the latest.");
91
+
92
+ return null;
93
+ }
94
+ if ($check) {
95
+ $this->components->info("nevela/laravel {$installed} → {$latest} is available.");
96
+
97
+ return null;
98
+ }
99
+
100
+ $this->components->info("Updating nevela/laravel {$installed} → {$latest}");
101
+ $composer = new Process(['composer', 'update', 'nevela/laravel', '--no-interaction', '--no-progress'], base_path(), null, null, null);
102
+ $composer->run(fn ($type, $buffer) => $this->output->write($buffer));
103
+ if (! $composer->isSuccessful()) {
104
+ $this->components->error('Composer could not update nevela/laravel. Nothing else was changed.');
105
+
106
+ return self::FAILURE;
107
+ }
108
+
109
+ // Composer succeeds even when composer.json doesn't allow the new version. Read what
110
+ // is actually on disk now, not what this process loaded before the update.
111
+ $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
112
+ $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
113
+ if ($now === $installed) {
114
+ $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.');
115
+
116
+ return null;
117
+ }
118
+
119
+ // The package's own files have just been replaced. Finish in a new process, so the
120
+ // rest runs on the new code instead of a mix of old and new.
121
+ $php = (new PhpExecutableFinder)->find(false) ?: 'php';
122
+ $rest = new Process([$php, 'artisan', 'nevela:update', '--skip-package'], base_path(), null, null, null);
123
+ $rest->run(fn ($type, $buffer) => $this->output->write($buffer));
124
+
125
+ return $rest->getExitCode() ?? self::FAILURE;
126
+ }
127
+
128
+ private function updateDashboard(bool $check): int
129
+ {
130
+ $web = config('nevela.web_path');
131
+ if (! $web || ! is_dir($web)) {
132
+ $this->components->info('No dashboard at nevela.web_path, so there is nothing more to update.');
133
+
134
+ return self::SUCCESS;
135
+ }
136
+ $web = rtrim(str_replace('\\', '/', realpath($web) ?: $web), '/');
137
+ $marker = "{$web}/.nevela.json";
138
+ $from = is_file($marker) ? (json_decode((string) file_get_contents($marker), true)['template'] ?? self::FIRST_TRACKED) : self::FIRST_TRACKED;
139
+ // In --check the package hasn't been updated, so look at the newest dashboard there is.
140
+ $to = $check ? (Releases::latest() ?? Nevela::VERSION) : Nevela::VERSION;
141
+
142
+ if (version_compare($from, $to, '>=')) {
143
+ $this->components->info("The dashboard is on the latest template ({$from}).");
144
+
145
+ return self::SUCCESS;
146
+ }
147
+
148
+ try {
149
+ $base = $this->template($from);
150
+ $next = $this->template($to);
151
+ } catch (Throwable $e) {
152
+ $this->components->error("Couldn't download the dashboard template ({$e->getMessage()}). The dashboard was left as it is; run this again later.");
153
+
154
+ return self::FAILURE;
155
+ }
156
+
157
+ $paths = array_unique([...array_keys($base), ...array_keys($next)]);
158
+ $yours = [];
159
+ foreach ($paths as $path) {
160
+ $yours[$path] = is_file("{$web}/{$path}") ? (string) file_get_contents("{$web}/{$path}") : null;
161
+ }
162
+
163
+ // package.json is merged, not replaced: it has your app's name and your own dependencies.
164
+ $packages = null;
165
+ if (isset($base['package.json'], $next['package.json'], $yours['package.json'])) {
166
+ $packages = DashboardUpdate::mergePackageJson($yours['package.json'], $base['package.json'], $next['package.json']);
167
+ }
168
+ unset($base['package.json'], $next['package.json']);
169
+
170
+ $plan = DashboardUpdate::plan($base, $next, $yours);
171
+ $this->components->info(($check ? 'Dashboard: what would change, ' : 'Dashboard: ')."template {$from} → {$to}");
172
+
173
+ $counts = [DashboardUpdate::UPDATE => 0, DashboardUpdate::ADD => 0, DashboardUpdate::CONFLICT => 0, DashboardUpdate::REMOVED => 0];
174
+ foreach ($plan as $path => $action) {
175
+ $counts[$action]++;
176
+ if (! $check && ($action === DashboardUpdate::UPDATE || $action === DashboardUpdate::ADD)) {
177
+ $target = "{$web}/{$path}";
178
+ if (! is_dir(dirname($target))) {
179
+ mkdir(dirname($target), 0775, true);
180
+ }
181
+ file_put_contents($target, $next[$path]);
182
+ }
183
+ $this->components->twoColumnDetail($path, match ($action) {
184
+ DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
185
+ DashboardUpdate::ADD => $check ? '<fg=green>would add</>' : '<fg=green>added</>',
186
+ DashboardUpdate::CONFLICT => '<fg=yellow>kept yours — changed in the template too</>',
187
+ DashboardUpdate::REMOVED => '<fg=gray>no longer in the template</>',
188
+ });
189
+ }
190
+
191
+ if ($packages && $packages['changed']) {
192
+ foreach ($packages['changed'] as $name => $change) {
193
+ $this->components->twoColumnDetail("package.json: {$name}", '<fg=blue>'.$change.'</>');
194
+ }
195
+ if (! $check) {
196
+ file_put_contents("{$web}/package.json", $packages['json']);
197
+ }
198
+ }
199
+ foreach ($packages['kept'] ?? [] as $name => $versions) {
200
+ $this->components->twoColumnDetail("package.json: {$name}", "<fg=yellow>kept yours, {$versions}</>");
201
+ }
202
+
203
+ if ($plan === [] && ! ($packages['changed'] ?? [])) {
204
+ $this->line(' Nothing in the dashboard needed changing.');
205
+ }
206
+ $this->newLine();
207
+
208
+ if (! $check) {
209
+ file_put_contents($marker, json_encode(['template' => $to], JSON_PRETTY_PRINT)."\n");
210
+ }
211
+ if ($counts[DashboardUpdate::CONFLICT] > 0) {
212
+ $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");
213
+ }
214
+ if ($counts[DashboardUpdate::REMOVED] > 0) {
215
+ $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.");
216
+ }
217
+ if (! $check && ($packages['changed'] ?? [])) {
218
+ $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && pnpm install</> (or npm install).');
219
+ }
220
+
221
+ return self::SUCCESS;
222
+ }
223
+
224
+ /**
225
+ * The dashboard as it shipped in a version: path => contents.
226
+ *
227
+ * It comes from the create-nevela package on npm, which carries the dashboard and is
228
+ * released with the same version number as this package.
229
+ *
230
+ * @return array<string, string>
231
+ */
232
+ private function template(string $version): array
233
+ {
234
+ $dir = rtrim(sys_get_temp_dir(), '/\\').DIRECTORY_SEPARATOR.'nevela-template-'.$version.'-'.bin2hex(random_bytes(4));
235
+ mkdir($dir, 0775, true);
236
+ $archive = $dir.DIRECTORY_SEPARATOR.'template.tgz';
237
+
238
+ try {
239
+ // NEVELA_TEMPLATE_DIR: a folder of create-nevela-<version>.tgz files to use instead
240
+ // of npm. For working on Nevela itself, and for updating without a connection.
241
+ $local = rtrim((string) env('NEVELA_TEMPLATE_DIR', ''), '/\\');
242
+ if ($local !== '' && is_file("{$local}/create-nevela-{$version}.tgz")) {
243
+ copy("{$local}/create-nevela-{$version}.tgz", $archive);
244
+ } else {
245
+ $response = Http::timeout(60)->get("https://registry.npmjs.org/create-nevela/-/create-nevela-{$version}.tgz");
246
+ if (! $response->successful()) {
247
+ throw new \RuntimeException("create-nevela {$version} is not on npm (HTTP {$response->status()})");
248
+ }
249
+ file_put_contents($archive, $response->body());
250
+ }
251
+ (new PharData($archive))->extractTo($dir, null, true);
252
+
253
+ $root = $dir.DIRECTORY_SEPARATOR.'package'.DIRECTORY_SEPARATOR.'template'.DIRECTORY_SEPARATOR.'web';
254
+ if (! is_dir($root)) {
255
+ throw new \RuntimeException("create-nevela {$version} has no dashboard template in it");
256
+ }
257
+ $files = [];
258
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
259
+ $path = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
260
+ // npm can't carry .gitignore files, so the template holds them as _gitignore.
261
+ $path = preg_replace('#(^|/)_gitignore$#', '$1.gitignore', $path);
262
+ $files[$path] = (string) file_get_contents($file->getPathname());
263
+ }
264
+
265
+ return $files;
266
+ } finally {
267
+ $this->removeDirectory($dir);
268
+ }
269
+ }
270
+
271
+ /** The folder Composer installs the package from, when it isn't coming from Packagist. */
272
+ private function installedByPath(): ?string
273
+ {
274
+ $composer = json_decode((string) @file_get_contents(base_path('composer.json')), true) ?: [];
275
+ foreach ($composer['repositories'] ?? [] as $repository) {
276
+ if (($repository['type'] ?? null) === 'path' && str_contains((string) ($repository['url'] ?? ''), 'laravel')) {
277
+ return (string) $repository['url'];
278
+ }
279
+ }
280
+
281
+ return null;
282
+ }
283
+
284
+ private function relative(string $path): string
285
+ {
286
+ $base = rtrim(str_replace('\\', '/', base_path()), '/');
287
+ $parent = dirname($base);
288
+
289
+ return str_starts_with($path, $parent.'/') ? '../'.substr($path, strlen($parent) + 1) : $path;
290
+ }
291
+
292
+ private function removeDirectory(string $dir): void
293
+ {
294
+ if (! is_dir($dir)) {
295
+ return;
296
+ }
297
+ $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
298
+ foreach ($items as $item) {
299
+ $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
300
+ }
301
+ @rmdir($dir);
302
+ }
303
+ }
@@ -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
+ }
@@ -20,12 +20,21 @@ final class GeneratedFile
20
20
 
21
21
  public const TARGET_WEB = 'web';
22
22
 
23
+ /** The top of the project, above the Laravel app and the dashboard. */
24
+ public const TARGET_ROOT = 'root';
25
+
26
+ public readonly string $contents;
27
+
23
28
  public function __construct(
24
29
  public readonly string $target,
25
30
  public readonly string $path,
26
- public readonly string $contents,
31
+ string $contents,
27
32
  public readonly string $mode = self::MODE_BLOCK,
28
33
  /** For MODE_ONCE: a glob (relative to the target root) that, when it matches, means the file already exists under another name. */
29
34
  public readonly ?string $existsGlob = null,
30
- ) {}
35
+ ) {
36
+ // The templates take their line endings from this package's own source files, which
37
+ // git may have checked out with CRLF on Windows. Generated code is always LF.
38
+ $this->contents = str_replace("\r\n", "\n", $contents);
39
+ }
31
40
  }