create-nevela 0.0.0-stage → 0.1.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -2
  3. package/index.mjs +363 -0
  4. package/package.json +37 -6
  5. package/packagist.mjs +25 -0
  6. package/template/laravel/LICENSE +21 -0
  7. package/template/laravel/README.md +55 -0
  8. package/template/laravel/composer.json +59 -0
  9. package/template/laravel/config/nevela.php +29 -0
  10. package/template/laravel/src/Console/GenerateCommand.php +77 -0
  11. package/template/laravel/src/Console/MakeResourceCommand.php +66 -0
  12. package/template/laravel/src/Console/SeedCommand.php +124 -0
  13. package/template/laravel/src/Console/UserCommand.php +59 -0
  14. package/template/laravel/src/Generator/GeneratedFile.php +31 -0
  15. package/template/laravel/src/Generator/ResourceGenerator.php +639 -0
  16. package/template/laravel/src/Generator/Writer.php +143 -0
  17. package/template/laravel/src/Http/FlareErrors.php +92 -0
  18. package/template/laravel/src/Http/ResourceRequest.php +67 -0
  19. package/template/laravel/src/Http/TokenController.php +65 -0
  20. package/template/laravel/src/Nevela.php +151 -0
  21. package/template/laravel/src/NevelaServiceProvider.php +65 -0
  22. package/template/laravel/src/Support/Descriptor.php +174 -0
  23. package/template/laravel/src/Support/Fake.php +171 -0
  24. package/template/laravel/src/Support/Field.php +146 -0
  25. package/template/laravel/src/Support/Issue.php +9 -0
  26. package/template/laravel/src/Support/ListQuery.php +125 -0
  27. package/template/laravel/src/Support/Naming.php +79 -0
  28. package/template/web/.env.example +2 -0
  29. package/template/web/LICENSE-flare +21 -0
  30. package/template/web/README.md +40 -0
  31. package/template/web/_gitignore +10 -0
  32. package/template/web/app/auth-actions.ts +47 -0
  33. package/template/web/app/dashboard/actions.ts +127 -0
  34. package/template/web/app/dashboard/error.tsx +37 -0
  35. package/template/web/app/dashboard/import-actions.ts +89 -0
  36. package/template/web/app/dashboard/layout.tsx +44 -0
  37. package/template/web/app/dashboard/loading.tsx +18 -0
  38. package/template/web/app/dashboard/not-found.tsx +30 -0
  39. package/template/web/app/dashboard/page.tsx +101 -0
  40. package/template/web/app/error.tsx +55 -0
  41. package/template/web/app/globals.css +255 -0
  42. package/template/web/app/layout.tsx +37 -0
  43. package/template/web/app/loading.tsx +22 -0
  44. package/template/web/app/not-found.tsx +48 -0
  45. package/template/web/app/page.tsx +142 -0
  46. package/template/web/app/sign-in/page.tsx +21 -0
  47. package/template/web/components/auth/auth-shell.tsx +284 -0
  48. package/template/web/components/auth/sign-in-form.tsx +50 -0
  49. package/template/web/components/auth/ui.tsx +96 -0
  50. package/template/web/components/dashboard/column-menu.tsx +66 -0
  51. package/template/web/components/dashboard/dashboard-header.tsx +115 -0
  52. package/template/web/components/dashboard/dashboard-sidebar.tsx +229 -0
  53. package/template/web/components/dashboard/dropzone.tsx +92 -0
  54. package/template/web/components/dashboard/editable.ts +12 -0
  55. package/template/web/components/dashboard/export-button.tsx +41 -0
  56. package/template/web/components/dashboard/fields/country-field.tsx +152 -0
  57. package/template/web/components/dashboard/fields/date-field.tsx +112 -0
  58. package/template/web/components/dashboard/fields/field-widget.tsx +318 -0
  59. package/template/web/components/dashboard/fields/file-cell.tsx +68 -0
  60. package/template/web/components/dashboard/fields/file-field.tsx +310 -0
  61. package/template/web/components/dashboard/fields/multi-select-field.tsx +46 -0
  62. package/template/web/components/dashboard/fields/phone-field.tsx +86 -0
  63. package/template/web/components/dashboard/fields/relation-field.tsx +154 -0
  64. package/template/web/components/dashboard/import-dialog.tsx +540 -0
  65. package/template/web/components/dashboard/inline-cell.tsx +165 -0
  66. package/template/web/components/dashboard/local-time.tsx +77 -0
  67. package/template/web/components/dashboard/page-header.tsx +57 -0
  68. package/template/web/components/dashboard/query.ts +27 -0
  69. package/template/web/components/dashboard/record-actions.tsx +113 -0
  70. package/template/web/components/dashboard/record-detail.tsx +182 -0
  71. package/template/web/components/dashboard/resource-form-page.tsx +64 -0
  72. package/template/web/components/dashboard/resource-form-sheet.tsx +86 -0
  73. package/template/web/components/dashboard/resource-form.tsx +261 -0
  74. package/template/web/components/dashboard/resource-icon.tsx +89 -0
  75. package/template/web/components/dashboard/resource-stats.tsx +49 -0
  76. package/template/web/components/dashboard/resource-table-toolbar.tsx +95 -0
  77. package/template/web/components/dashboard/resource-table.tsx +411 -0
  78. package/template/web/components/dashboard/row-actions.tsx +156 -0
  79. package/template/web/components/dashboard/skeletons.tsx +146 -0
  80. package/template/web/components/dashboard/stat-card.tsx +69 -0
  81. package/template/web/components/dashboard/table-selection.tsx +185 -0
  82. package/template/web/components/dashboard/user-menu.tsx +63 -0
  83. package/template/web/components/marketing/product-preview.tsx +70 -0
  84. package/template/web/components/marketing/site-header.tsx +39 -0
  85. package/template/web/components/sign-out-button.tsx +18 -0
  86. package/template/web/components/ui/alert-dialog.tsx +196 -0
  87. package/template/web/components/ui/alert.tsx +65 -0
  88. package/template/web/components/ui/avatar.tsx +108 -0
  89. package/template/web/components/ui/badge.tsx +53 -0
  90. package/template/web/components/ui/breadcrumb.tsx +110 -0
  91. package/template/web/components/ui/button.tsx +65 -0
  92. package/template/web/components/ui/calendar.tsx +220 -0
  93. package/template/web/components/ui/card.tsx +92 -0
  94. package/template/web/components/ui/checkbox.tsx +25 -0
  95. package/template/web/components/ui/command.tsx +184 -0
  96. package/template/web/components/ui/dialog.tsx +158 -0
  97. package/template/web/components/ui/dropdown-menu.tsx +256 -0
  98. package/template/web/components/ui/empty.tsx +103 -0
  99. package/template/web/components/ui/field.tsx +248 -0
  100. package/template/web/components/ui/input-group.tsx +170 -0
  101. package/template/web/components/ui/input.tsx +20 -0
  102. package/template/web/components/ui/label.tsx +23 -0
  103. package/template/web/components/ui/pagination.tsx +127 -0
  104. package/template/web/components/ui/popover.tsx +88 -0
  105. package/template/web/components/ui/progress.tsx +30 -0
  106. package/template/web/components/ui/radio-group.tsx +29 -0
  107. package/template/web/components/ui/select.tsx +189 -0
  108. package/template/web/components/ui/separator.tsx +27 -0
  109. package/template/web/components/ui/sheet.tsx +142 -0
  110. package/template/web/components/ui/sidebar.tsx +726 -0
  111. package/template/web/components/ui/skeleton.tsx +13 -0
  112. package/template/web/components/ui/sonner.tsx +58 -0
  113. package/template/web/components/ui/spinner.tsx +15 -0
  114. package/template/web/components/ui/switch.tsx +34 -0
  115. package/template/web/components/ui/table.tsx +115 -0
  116. package/template/web/components/ui/textarea.tsx +17 -0
  117. package/template/web/components/ui/tooltip.tsx +56 -0
  118. package/template/web/components.json +21 -0
  119. package/template/web/hooks/use-mobile.ts +19 -0
  120. package/template/web/lib/cache.ts +83 -0
  121. package/template/web/lib/csv.ts +24 -0
  122. package/template/web/lib/dashboard-nav.ts +17 -0
  123. package/template/web/lib/dashboard.ts +130 -0
  124. package/template/web/lib/laravel.ts +57 -0
  125. package/template/web/lib/number.ts +33 -0
  126. package/template/web/lib/resource/index.ts +4 -0
  127. package/template/web/lib/resource/query.ts +149 -0
  128. package/template/web/lib/resource/store.ts +149 -0
  129. package/template/web/lib/session.ts +42 -0
  130. package/template/web/lib/site.ts +56 -0
  131. package/template/web/lib/suggest.ts +35 -0
  132. package/template/web/lib/theme.ts +83 -0
  133. package/template/web/lib/utils.ts +6 -0
  134. package/template/web/lib/views.ts +14 -0
  135. package/template/web/next.config.ts +8 -0
  136. package/template/web/package.json +42 -0
  137. package/template/web/policies/index.ts +6 -0
  138. package/template/web/postcss.config.mjs +7 -0
  139. package/template/web/proxy.ts +20 -0
  140. package/template/web/tsconfig.json +46 -0
  141. package/template.mjs +94 -0
@@ -0,0 +1,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
+ /** @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
+ }
@@ -0,0 +1,66 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use InvalidArgumentException;
7
+ use Nevela\Laravel\Nevela;
8
+ use Nevela\Laravel\Support\Descriptor;
9
+ use Nevela\Laravel\Support\Naming;
10
+
11
+ final class MakeResourceCommand extends Command
12
+ {
13
+ protected $signature = 'nevela:resource
14
+ {name : PascalCase resource name, e.g. Product}
15
+ {--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
16
+ {--icon= : Lucide icon name for the dashboard, e.g. package}
17
+ {--group= : Sidebar group in the dashboard}
18
+ {--force : Overwrite an existing descriptor}';
19
+
20
+ protected $description = 'Describe a resource once, then generate its Laravel API and Flare dashboard descriptor';
21
+
22
+ public function handle(): int
23
+ {
24
+ try {
25
+ $descriptor = Descriptor::fromSpec(
26
+ $this->argument('name'),
27
+ (string) ($this->option('fields') ?? $this->ask('Fields (e.g. name:string, price:money, active:boolean)')),
28
+ $this->option('icon'),
29
+ $this->option('group'),
30
+ );
31
+ } catch (InvalidArgumentException $e) {
32
+ $this->components->error($e->getMessage());
33
+
34
+ return self::FAILURE;
35
+ }
36
+
37
+ $dir = rtrim(config('nevela.descriptors_path'), '/\\');
38
+ $path = $dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json';
39
+ if (is_file($path) && ! $this->option('force')) {
40
+ $this->components->error("{$descriptor->name} already has a descriptor. Edit it and run: php artisan nevela:generate {$descriptor->name}");
41
+
42
+ return self::FAILURE;
43
+ }
44
+ if (! is_dir($dir)) {
45
+ mkdir($dir, 0775, true);
46
+ }
47
+ file_put_contents($path, $descriptor->toJson());
48
+ $this->components->info("Described {$descriptor->name} in ".str_replace(base_path().DIRECTORY_SEPARATOR, '', $path));
49
+
50
+ Nevela::forget();
51
+ $status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
52
+
53
+ $this->newLine();
54
+ $this->line(' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
55
+ $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
+
57
+ return $status;
58
+ }
59
+
60
+ private function prefix(): string
61
+ {
62
+ $prefix = trim((string) config('nevela.prefix', 'api'), '/');
63
+
64
+ return $prefix === '' ? '' : $prefix.'/';
65
+ }
66
+ }
@@ -0,0 +1,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\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
+ }
@@ -0,0 +1,59 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Hash;
7
+ use Illuminate\Support\Facades\Validator;
8
+
9
+ final class UserCommand extends Command
10
+ {
11
+ protected $signature = 'nevela:user
12
+ {--name= : The person\'s name}
13
+ {--email= : The address they sign in with}
14
+ {--password= : Their password. Leave out to be asked for it without it showing}';
15
+
16
+ protected $description = 'Create a user who can sign in to the dashboard';
17
+
18
+ public function handle(): int
19
+ {
20
+ $model = config('auth.providers.users.model');
21
+ if (! is_string($model) || ! class_exists($model)) {
22
+ $this->components->error('No user model is configured in config/auth.php (auth.providers.users.model).');
23
+
24
+ return self::FAILURE;
25
+ }
26
+
27
+ $input = [
28
+ 'name' => $this->option('name') ?? $this->ask('Name'),
29
+ 'email' => $this->option('email') ?? $this->ask('Email'),
30
+ 'password' => $this->option('password') ?? $this->secret('Password (at least 8 characters)'),
31
+ ];
32
+
33
+ $table = (new $model)->getTable();
34
+ $validator = Validator::make($input, [
35
+ 'name' => ['required', 'string', 'max:255'],
36
+ 'email' => ['required', 'email', 'max:255', "unique:{$table},email"],
37
+ 'password' => ['required', 'string', 'min:8'],
38
+ ]);
39
+ if ($validator->fails()) {
40
+ foreach ($validator->errors()->all() as $message) {
41
+ $this->components->error($message);
42
+ }
43
+
44
+ return self::FAILURE;
45
+ }
46
+
47
+ // forceFill: this is the operator creating an account, not a form to guard.
48
+ $user = (new $model)->forceFill([
49
+ 'name' => $input['name'],
50
+ 'email' => $input['email'],
51
+ 'password' => Hash::make($input['password']),
52
+ ]);
53
+ $user->save();
54
+
55
+ $this->components->info("Created {$input['email']}. They can sign in to the dashboard now.");
56
+
57
+ return self::SUCCESS;
58
+ }
59
+ }
@@ -0,0 +1,31 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Generator;
4
+
5
+ /**
6
+ * One file the generator wants to exist.
7
+ *
8
+ * - MODE_BLOCK: the file carries a `nevela:generated:start … end` block. Regeneration
9
+ * rewrites only that block; everything outside it is yours. If you edited inside the
10
+ * block, regeneration skips the file (the hash no longer matches) unless forced.
11
+ * - MODE_ONCE: written when missing, never touched again (migrations, policies).
12
+ */
13
+ final class GeneratedFile
14
+ {
15
+ public const MODE_BLOCK = 'block';
16
+
17
+ public const MODE_ONCE = 'once';
18
+
19
+ public const TARGET_API = 'api';
20
+
21
+ public const TARGET_WEB = 'web';
22
+
23
+ public function __construct(
24
+ public readonly string $target,
25
+ public readonly string $path,
26
+ public readonly string $contents,
27
+ public readonly string $mode = self::MODE_BLOCK,
28
+ /** For MODE_ONCE: a glob (relative to the target root) that, when it matches, means the file already exists under another name. */
29
+ public readonly ?string $existsGlob = null,
30
+ ) {}
31
+ }