create-nevela 0.1.0 → 0.1.2

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.
@@ -1,77 +1,107 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Console;
4
-
5
- use Illuminate\Console\Command;
6
- use InvalidArgumentException;
7
- use Nevela\Laravel\Generator\ResourceGenerator;
8
- use Nevela\Laravel\Generator\Writer;
9
- use Nevela\Laravel\Nevela;
10
- use Nevela\Laravel\Support\Descriptor;
11
-
12
- final class GenerateCommand extends Command
13
- {
14
- protected $signature = 'nevela:generate
15
- {name? : Only this resource (e.g. Product). Default: every descriptor}
16
- {--force : Rewrite generated blocks even where you edited inside them}';
17
-
18
- protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
19
-
20
- public function handle(): int
21
- {
22
- Nevela::forget();
23
- try {
24
- $all = Nevela::all();
25
- $targets = $this->argument('name') ? [Nevela::resource($this->argument('name'))] : $all;
26
- } catch (InvalidArgumentException $e) {
27
- $this->components->error($e->getMessage());
28
-
29
- return self::FAILURE;
30
- }
31
- // With no descriptors there is still an (empty) route file and web registry to
32
- // write, so a new app's dashboard builds before its first resource exists.
33
- $status = self::generate($this, $targets, $all, (bool) $this->option('force'));
34
- if ($all === []) {
35
- $this->components->warn('No descriptors yet. Start with: php artisan nevela:resource Product --fields="name:string, price:money"');
36
- }
37
-
38
- return $status;
39
- }
40
-
41
- /** @param list<Descriptor> $targets @param list<Descriptor> $all */
42
- public static function generate(Command $command, array $targets, array $all, bool $force): int
43
- {
44
- $generator = new ResourceGenerator;
45
- $files = [];
46
- foreach ($targets as $descriptor) {
47
- array_push($files, ...$generator->forResource($descriptor));
48
- }
49
- $files[] = $generator->routes($all);
50
- $files[] = $generator->registry($all);
51
-
52
- $roots = ['api' => base_path()];
53
- if (($web = config('nevela.web_path')) && is_dir($web)) {
54
- $roots['web'] = $web;
55
- }
56
-
57
- $report = (new Writer($roots, $force))->write($files);
58
- $edited = 0;
59
- foreach ($report as $row) {
60
- $path = str_replace([base_path().DIRECTORY_SEPARATOR, '\\'], ['', '/'], $row['path']);
61
- $command->components->twoColumnDetail($path, match ($row['status']) {
62
- 'created' => '<fg=green>created</>',
63
- 'updated' => '<fg=blue>updated</>',
64
- 'edited' => '<fg=yellow>skipped — edited inside generated block</>',
65
- 'exists' => '<fg=gray>exists (yours)</>',
66
- 'skipped' => '<fg=gray>skipped (no web app at nevela.web_path yet)</>',
67
- default => '<fg=gray>unchanged</>',
68
- });
69
- $edited += $row['status'] === 'edited' ? 1 : 0;
70
- }
71
- if ($edited) {
72
- $command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
73
- }
74
-
75
- return self::SUCCESS;
76
- }
77
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use InvalidArgumentException;
7
+ use Nevela\Laravel\Generator\ResourceGenerator;
8
+ use Nevela\Laravel\Generator\Writer;
9
+ use Nevela\Laravel\Nevela;
10
+ use Nevela\Laravel\Support\Descriptor;
11
+
12
+ final class GenerateCommand extends Command
13
+ {
14
+ protected $signature = 'nevela:generate
15
+ {name? : Only this resource (e.g. Product). Default: every descriptor}
16
+ {--force : Rewrite generated blocks even where you edited inside them}';
17
+
18
+ protected $description = 'Regenerate Laravel code and Flare descriptors from nevela/resources/*.json';
19
+
20
+ public function handle(): int
21
+ {
22
+ Nevela::forget();
23
+ try {
24
+ $all = Nevela::all();
25
+ $targets = $this->argument('name') ? [Nevela::resource($this->argument('name'))] : $all;
26
+ } catch (InvalidArgumentException $e) {
27
+ $this->components->error($e->getMessage());
28
+
29
+ return self::FAILURE;
30
+ }
31
+ // With no descriptors there is still an (empty) route file and web registry to
32
+ // write, so a new app's dashboard builds before its first resource exists.
33
+ $status = self::generate($this, $targets, $all, (bool) $this->option('force'));
34
+ if ($all === []) {
35
+ $this->components->warn('No descriptors yet. Start with: php artisan nevela:resource Product --fields="name:string, price:money"');
36
+ }
37
+
38
+ return $status;
39
+ }
40
+
41
+ /**
42
+ * The top of the project, where `php nevela` lives: nevela.root_path if set, otherwise
43
+ * two levels up when the app sits in <project>/apps/<name>. Null for a Laravel app that
44
+ * isn't inside such a project, which gets no launcher.
45
+ */
46
+ public static function projectRoot(): ?string
47
+ {
48
+ $configured = config('nevela.root_path');
49
+ if ($configured === false) {
50
+ return null;
51
+ }
52
+ if (is_string($configured) && $configured !== '') {
53
+ return is_dir($configured) ? rtrim((string) realpath($configured), '/\\') : null;
54
+ }
55
+ $base = base_path();
56
+
57
+ return basename(dirname($base)) === 'apps' ? dirname($base, 2) : null;
58
+ }
59
+
60
+ /** @param list<Descriptor> $targets @param list<Descriptor> $all */
61
+ public static function generate(Command $command, array $targets, array $all, bool $force): int
62
+ {
63
+ $generator = new ResourceGenerator;
64
+ $files = [];
65
+ foreach ($targets as $descriptor) {
66
+ array_push($files, ...$generator->forResource($descriptor));
67
+ }
68
+ $files[] = $generator->routes($all);
69
+ $files[] = $generator->registry($all);
70
+
71
+ $roots = ['api' => base_path()];
72
+ if (($web = config('nevela.web_path')) && is_dir($web)) {
73
+ $roots['web'] = $web;
74
+ }
75
+ if ($root = self::projectRoot()) {
76
+ $roots['root'] = $root;
77
+ $apiFromRoot = ltrim(str_replace('\\', '/', substr(base_path(), strlen($root))), '/');
78
+ $files[] = $generator->launcher($apiFromRoot);
79
+ }
80
+
81
+ $report = (new Writer($roots, $force))->write($files);
82
+ $edited = 0;
83
+ // Shown relative to the Laravel app, like the dashboard's files: ../../nevela
84
+ $rootShown = isset($apiFromRoot) ? rtrim(str_repeat('../', substr_count($apiFromRoot, '/') + 1), '/') : '';
85
+ foreach ($report as $row) {
86
+ $path = $row['path'];
87
+ if (isset($roots['root']) && str_starts_with($path, $roots['root'].DIRECTORY_SEPARATOR) && ! str_starts_with($path, base_path().DIRECTORY_SEPARATOR)) {
88
+ $path = $rootShown.substr($path, strlen($roots['root']));
89
+ }
90
+ $path = str_replace([base_path().DIRECTORY_SEPARATOR, '\\'], ['', '/'], $path);
91
+ $command->components->twoColumnDetail($path, match ($row['status']) {
92
+ 'created' => '<fg=green>created</>',
93
+ 'updated' => '<fg=blue>updated</>',
94
+ 'edited' => '<fg=yellow>skipped — edited inside generated block</>',
95
+ 'exists' => '<fg=gray>exists (yours)</>',
96
+ 'skipped' => '<fg=gray>skipped (no web app at nevela.web_path yet)</>',
97
+ default => '<fg=gray>unchanged</>',
98
+ });
99
+ $edited += $row['status'] === 'edited' ? 1 : 0;
100
+ }
101
+ if ($edited) {
102
+ $command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
103
+ }
104
+
105
+ return self::SUCCESS;
106
+ }
107
+ }
@@ -15,7 +15,8 @@ final class MakeResourceCommand extends Command
15
15
  {--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
16
16
  {--icon= : Lucide icon name for the dashboard, e.g. package}
17
17
  {--group= : Sidebar group in the dashboard}
18
- {--force : Overwrite an existing descriptor}';
18
+ {--force : Overwrite an existing descriptor}
19
+ {--migrate : Run the new migration straight away}';
19
20
 
20
21
  protected $description = 'Describe a resource once, then generate its Laravel API and Flare dashboard descriptor';
21
22
 
@@ -50,8 +51,14 @@ final class MakeResourceCommand extends Command
50
51
  Nevela::forget();
51
52
  $status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
52
53
 
54
+ if ($status === self::SUCCESS && $this->option('migrate')) {
55
+ $status = $this->call('migrate');
56
+ }
57
+
53
58
  $this->newLine();
54
- $this->line(' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
59
+ $this->line($this->option('migrate')
60
+ ? ' Next: tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.'
61
+ : ' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
55
62
  $this->line(" API: GET|POST /{$this->prefix()}{$descriptor->slug} · GET|PUT|PATCH|DELETE /{$this->prefix()}{$descriptor->slug}/{id} · GET /{$this->prefix()}{$descriptor->slug}/_stats");
56
63
 
57
64
  return $status;
@@ -0,0 +1,304 @@
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 PharData;
10
+ use RecursiveDirectoryIterator;
11
+ use RecursiveIteratorIterator;
12
+ use Symfony\Component\Process\PhpExecutableFinder;
13
+ use Symfony\Component\Process\Process;
14
+ use Throwable;
15
+
16
+ final class UpdateCommand extends Command
17
+ {
18
+ protected $signature = 'nevela:update
19
+ {--check : Show what would change, and change nothing}
20
+ {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}';
21
+
22
+ protected $description = 'Update Nevela: the package, the generated code and the dashboard';
23
+
24
+ /** Apps created before the dashboard recorded its version all came from this template. */
25
+ private const FIRST_TRACKED = '0.1.1';
26
+
27
+ public function handle(): int
28
+ {
29
+ $check = (bool) $this->option('check');
30
+
31
+ if (! $this->option('skip-package')) {
32
+ $status = $this->updatePackage($check);
33
+ if ($status !== null) {
34
+ return $status;
35
+ }
36
+ }
37
+
38
+ if (! $check) {
39
+ $this->components->task('Regenerating from the descriptors', fn () => $this->callSilently('nevela:generate') === self::SUCCESS);
40
+ }
41
+
42
+ return $this->updateDashboard($check);
43
+ }
44
+
45
+ /**
46
+ * Update the Composer package. Returns an exit code when the rest of the work was
47
+ * handed to a fresh process, or null to carry on in this one.
48
+ */
49
+ private function updatePackage(bool $check): ?int
50
+ {
51
+ $installed = Nevela::VERSION;
52
+ $latest = $this->latestOnPackagist();
53
+
54
+ if ($folder = $this->installedByPath()) {
55
+ $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}.");
56
+ $this->line(' To move to Packagist: remove the "nevela" entry under "repositories" in composer.json, then run <fg=cyan>composer require nevela/laravel</>.');
57
+ $this->newLine();
58
+
59
+ return null;
60
+ }
61
+
62
+ if ($latest === null) {
63
+ $this->components->warn("Couldn't reach Packagist to look for a newer version. Carrying on with {$installed}.");
64
+
65
+ return null;
66
+ }
67
+ if (version_compare($latest, $installed, '<=')) {
68
+ $this->components->info("nevela/laravel {$installed} is the latest.");
69
+
70
+ return null;
71
+ }
72
+ if ($check) {
73
+ $this->components->info("nevela/laravel {$installed} → {$latest} is available.");
74
+
75
+ return null;
76
+ }
77
+
78
+ $this->components->info("Updating nevela/laravel {$installed} → {$latest}");
79
+ $composer = new Process(['composer', 'update', 'nevela/laravel', '--no-interaction', '--no-progress'], base_path(), null, null, null);
80
+ $composer->run(fn ($type, $buffer) => $this->output->write($buffer));
81
+ if (! $composer->isSuccessful()) {
82
+ $this->components->error('Composer could not update nevela/laravel. Nothing else was changed.');
83
+
84
+ return self::FAILURE;
85
+ }
86
+
87
+ // Composer succeeds even when composer.json doesn't allow the new version. Read what
88
+ // is actually on disk now, not what this process loaded before the update.
89
+ $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
90
+ $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
91
+ if ($now === $installed) {
92
+ $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.');
93
+
94
+ return null;
95
+ }
96
+
97
+ // The package's own files have just been replaced. Finish in a new process, so the
98
+ // rest runs on the new code instead of a mix of old and new.
99
+ $php = (new PhpExecutableFinder)->find(false) ?: 'php';
100
+ $rest = new Process([$php, 'artisan', 'nevela:update', '--skip-package'], base_path(), null, null, null);
101
+ $rest->run(fn ($type, $buffer) => $this->output->write($buffer));
102
+
103
+ return $rest->getExitCode() ?? self::FAILURE;
104
+ }
105
+
106
+ private function updateDashboard(bool $check): int
107
+ {
108
+ $web = config('nevela.web_path');
109
+ if (! $web || ! is_dir($web)) {
110
+ $this->components->info('No dashboard at nevela.web_path, so there is nothing more to update.');
111
+
112
+ return self::SUCCESS;
113
+ }
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;
117
+ // 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;
119
+
120
+ if (version_compare($from, $to, '>=')) {
121
+ $this->components->info("The dashboard is on the latest template ({$from}).");
122
+
123
+ return self::SUCCESS;
124
+ }
125
+
126
+ try {
127
+ $base = $this->template($from);
128
+ $next = $this->template($to);
129
+ } 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.");
131
+
132
+ return self::FAILURE;
133
+ }
134
+
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
+ }
140
+
141
+ // package.json is merged, not replaced: it has your app's name and your own dependencies.
142
+ $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']);
145
+ }
146
+ unset($base['package.json'], $next['package.json']);
147
+
148
+ $plan = DashboardUpdate::plan($base, $next, $yours);
149
+ $this->components->info(($check ? 'Dashboard: what would change, ' : 'Dashboard: ')."template {$from} → {$to}");
150
+
151
+ $counts = [DashboardUpdate::UPDATE => 0, DashboardUpdate::ADD => 0, DashboardUpdate::CONFLICT => 0, DashboardUpdate::REMOVED => 0];
152
+ 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);
158
+ }
159
+ file_put_contents($target, $next[$path]);
160
+ }
161
+ $this->components->twoColumnDetail($path, match ($action) {
162
+ DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
163
+ DashboardUpdate::ADD => $check ? '<fg=green>would add</>' : '<fg=green>added</>',
164
+ DashboardUpdate::CONFLICT => '<fg=yellow>kept yours — changed in the template too</>',
165
+ DashboardUpdate::REMOVED => '<fg=gray>no longer in the template</>',
166
+ });
167
+ }
168
+
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
+ }
176
+ }
177
+ foreach ($packages['kept'] ?? [] as $name => $versions) {
178
+ $this->components->twoColumnDetail("package.json: {$name}", "<fg=yellow>kept yours, {$versions}</>");
179
+ }
180
+
181
+ if ($plan === [] && ! ($packages['changed'] ?? [])) {
182
+ $this->line(' Nothing in the dashboard needed changing.');
183
+ }
184
+ $this->newLine();
185
+
186
+ if (! $check) {
187
+ file_put_contents($marker, json_encode(['template' => $to], JSON_PRETTY_PRINT)."\n");
188
+ }
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");
191
+ }
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.");
194
+ }
195
+ 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).');
197
+ }
198
+
199
+ return self::SUCCESS;
200
+ }
201
+
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
211
+ {
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';
215
+
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());
228
+ }
229
+ (new PharData($archive))->extractTo($dir, null, true);
230
+
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");
234
+ }
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());
241
+ }
242
+
243
+ return $files;
244
+ } finally {
245
+ $this->removeDirectory($dir);
246
+ }
247
+ }
248
+
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
+ /** The folder Composer installs the package from, when it isn't coming from Packagist. */
273
+ private function installedByPath(): ?string
274
+ {
275
+ $composer = json_decode((string) @file_get_contents(base_path('composer.json')), true) ?: [];
276
+ foreach ($composer['repositories'] ?? [] as $repository) {
277
+ if (($repository['type'] ?? null) === 'path' && str_contains((string) ($repository['url'] ?? ''), 'laravel')) {
278
+ return (string) $repository['url'];
279
+ }
280
+ }
281
+
282
+ return null;
283
+ }
284
+
285
+ private function relative(string $path): string
286
+ {
287
+ $base = rtrim(str_replace('\\', '/', base_path()), '/');
288
+ $parent = dirname($base);
289
+
290
+ return str_starts_with($path, $parent.'/') ? '../'.substr($path, strlen($parent) + 1) : $path;
291
+ }
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
+ }
@@ -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
  }