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.
- package/README.md +16 -5
- package/index.mjs +142 -63
- package/package.json +1 -1
- package/packagist.mjs +34 -25
- package/template/laravel/README.md +1 -0
- package/template/laravel/config/nevela.php +7 -0
- package/template/laravel/src/Console/GenerateCommand.php +107 -77
- package/template/laravel/src/Console/MakeResourceCommand.php +9 -2
- package/template/laravel/src/Console/UpdateCommand.php +304 -0
- package/template/laravel/src/Generator/GeneratedFile.php +11 -2
- package/template/laravel/src/Generator/ResourceGenerator.php +719 -639
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +66 -65
- package/template/laravel/src/Support/DashboardUpdate.php +110 -0
- package/template/web/package.json +1 -1
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
$
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
$
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
$
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
}
|