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.
- package/README.md +64 -55
- package/index.mjs +405 -418
- 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/DevCommand.php +104 -0
- package/template/laravel/src/Console/GenerateCommand.php +107 -77
- package/template/laravel/src/Console/MakeResourceCommand.php +17 -2
- package/template/laravel/src/Console/StatusCommand.php +172 -0
- package/template/laravel/src/Console/UpdateCommand.php +303 -0
- package/template/laravel/src/Console/VersionCommand.php +20 -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 +161 -151
- package/template/laravel/src/NevelaServiceProvider.php +79 -65
- package/template/laravel/src/Support/DashboardUpdate.php +110 -0
- package/template/laravel/src/Support/Releases.php +45 -0
- package/template/web/app/auth-actions.ts +12 -4
- package/template/web/package.json +1 -1
|
@@ -1,151 +1,161 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
-
use Illuminate\Http\JsonResponse;
|
|
7
|
-
use Illuminate\Http\Request;
|
|
8
|
-
use InvalidArgumentException;
|
|
9
|
-
use Nevela\Laravel\Support\Descriptor;
|
|
10
|
-
use Nevela\Laravel\Support\ListQuery;
|
|
11
|
-
use Nevela\Laravel\Support\Naming;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Runtime helpers the generated controllers call. Kept small and readable: each method is
|
|
15
|
-
* one step of Flare's REST contract.
|
|
16
|
-
*/
|
|
17
|
-
final class Nevela
|
|
18
|
-
{
|
|
19
|
-
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
20
|
-
public const VERSION = '0.1.
|
|
21
|
-
|
|
22
|
-
/** @var array<string, Descriptor> */
|
|
23
|
-
private static array $descriptors = [];
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
public static function
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
$
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
$
|
|
128
|
-
if ($
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
'
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
+
use Illuminate\Http\JsonResponse;
|
|
7
|
+
use Illuminate\Http\Request;
|
|
8
|
+
use InvalidArgumentException;
|
|
9
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
10
|
+
use Nevela\Laravel\Support\ListQuery;
|
|
11
|
+
use Nevela\Laravel\Support\Naming;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runtime helpers the generated controllers call. Kept small and readable: each method is
|
|
15
|
+
* one step of Flare's REST contract.
|
|
16
|
+
*/
|
|
17
|
+
final class Nevela
|
|
18
|
+
{
|
|
19
|
+
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
20
|
+
public const VERSION = '0.1.3';
|
|
21
|
+
|
|
22
|
+
/** @var array<string, Descriptor> */
|
|
23
|
+
private static array $descriptors = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A short id for this installation, from where it is on disk. It gives nothing away,
|
|
27
|
+
* and it differs between two apps on one machine, which is what it is for: telling
|
|
28
|
+
* which app is answering on a port.
|
|
29
|
+
*/
|
|
30
|
+
public static function fingerprint(): string
|
|
31
|
+
{
|
|
32
|
+
return substr(sha1(base_path()), 0, 12);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A resource's descriptor, from config('nevela.descriptors_path')/<kebab-name>.json. */
|
|
36
|
+
public static function resource(string $name): Descriptor
|
|
37
|
+
{
|
|
38
|
+
if (isset(self::$descriptors[$name])) {
|
|
39
|
+
return self::$descriptors[$name];
|
|
40
|
+
}
|
|
41
|
+
$path = rtrim(config('nevela.descriptors_path'), '/\\').DIRECTORY_SEPARATOR.Naming::kebab($name).'.json';
|
|
42
|
+
if (! is_file($path)) {
|
|
43
|
+
throw new InvalidArgumentException("No Nevela descriptor for {$name} at {$path}.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return self::$descriptors[$name] = Descriptor::fromJson((string) file_get_contents($path));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @return list<Descriptor> */
|
|
50
|
+
public static function all(): array
|
|
51
|
+
{
|
|
52
|
+
$dir = config('nevela.descriptors_path');
|
|
53
|
+
$all = [];
|
|
54
|
+
foreach (glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.json') ?: [] as $file) {
|
|
55
|
+
$d = Descriptor::fromJson((string) file_get_contents($file));
|
|
56
|
+
$all[] = self::$descriptors[$d->name] = $d;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return $all;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static function forget(): void
|
|
63
|
+
{
|
|
64
|
+
self::$descriptors = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* GET /{slug}: page, perPage, sort, q, filter[field] → { data, meta }.
|
|
69
|
+
*
|
|
70
|
+
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
|
|
71
|
+
*/
|
|
72
|
+
public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
|
|
73
|
+
{
|
|
74
|
+
$resource = self::resource($name);
|
|
75
|
+
$list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
|
|
76
|
+
if (! $list->ok()) {
|
|
77
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if ($list->q !== null) {
|
|
81
|
+
$columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
|
|
82
|
+
if ($columns !== []) {
|
|
83
|
+
$pattern = '%'.addcslashes($list->q, '%_\\').'%';
|
|
84
|
+
$query->where(function (Builder $where) use ($columns, $pattern) {
|
|
85
|
+
foreach ($columns as $column) {
|
|
86
|
+
$where->orWhereLike($column, $pattern);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
foreach ($list->filters as $filter) {
|
|
93
|
+
$filter['value'] === null
|
|
94
|
+
? $query->whereNull($filter['column'])
|
|
95
|
+
: $query->where($filter['column'], $filter['value']);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$query->reorder()
|
|
99
|
+
->orderBy($list->sort['column'], $list->sort['direction'])
|
|
100
|
+
->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
|
|
101
|
+
|
|
102
|
+
$page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
|
|
103
|
+
|
|
104
|
+
return response()->json([
|
|
105
|
+
'data' => $resourceClass::collection($page->getCollection())->resolve($request),
|
|
106
|
+
'meta' => [
|
|
107
|
+
'page' => $page->currentPage(),
|
|
108
|
+
'perPage' => $page->perPage(),
|
|
109
|
+
'total' => $page->total(),
|
|
110
|
+
'totalPages' => max(1, $page->lastPage()),
|
|
111
|
+
],
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
|
|
117
|
+
* What Flare's ResourceStats card shows above a table.
|
|
118
|
+
*/
|
|
119
|
+
public static function stats(Builder $query, string $name, Request $request): JsonResponse
|
|
120
|
+
{
|
|
121
|
+
$resource = self::resource($name);
|
|
122
|
+
$days = filter_var($request->query('days', 7), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 365]]);
|
|
123
|
+
if ($days === false) {
|
|
124
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'days', 'message' => 'Must be between 1 and 365.']]], 400);
|
|
125
|
+
}
|
|
126
|
+
$key = $request->query('field');
|
|
127
|
+
$field = is_string($key) ? ($resource->fields[$key] ?? null) : null;
|
|
128
|
+
if ($key !== null && ($field === null || ! in_array($field->kind, ['enum', 'boolean'], true))) {
|
|
129
|
+
return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'field', 'message' => 'Must be an enum or boolean field.']]], 400);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$now = now();
|
|
133
|
+
$since = $now->copy()->subDays($days);
|
|
134
|
+
$before = $now->copy()->subDays($days * 2);
|
|
135
|
+
$base = fn () => (clone $query)->reorder();
|
|
136
|
+
|
|
137
|
+
$values = [];
|
|
138
|
+
if ($field !== null) {
|
|
139
|
+
$column = $field->column();
|
|
140
|
+
foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
|
|
141
|
+
$value = $field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value;
|
|
142
|
+
$values[$value] = (int) $row->aggregate;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return response()->json([
|
|
147
|
+
'total' => $base()->count(),
|
|
148
|
+
'current' => $base()->where('created_at', '>=', $since)->count(),
|
|
149
|
+
'previous' => $base()->where('created_at', '>=', $before)->where('created_at', '<', $since)->count(),
|
|
150
|
+
'values' => (object) $values,
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
155
|
+
public static function handles(Request $request): bool
|
|
156
|
+
{
|
|
157
|
+
$prefix = trim((string) config('nevela.prefix', 'api'), '/');
|
|
158
|
+
|
|
159
|
+
return $prefix === '' || $request->is($prefix, $prefix.'/*');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -1,65 +1,79 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel;
|
|
4
|
-
|
|
5
|
-
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
6
|
-
use Illuminate\Support\Facades\Route;
|
|
7
|
-
use Illuminate\Support\ServiceProvider;
|
|
8
|
-
use Nevela\Laravel\Console\
|
|
9
|
-
use Nevela\Laravel\Console\
|
|
10
|
-
use Nevela\Laravel\Console\
|
|
11
|
-
use Nevela\Laravel\Console\
|
|
12
|
-
use Nevela\Laravel\
|
|
13
|
-
use Nevela\Laravel\
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
6
|
+
use Illuminate\Support\Facades\Route;
|
|
7
|
+
use Illuminate\Support\ServiceProvider;
|
|
8
|
+
use Nevela\Laravel\Console\DevCommand;
|
|
9
|
+
use Nevela\Laravel\Console\GenerateCommand;
|
|
10
|
+
use Nevela\Laravel\Console\MakeResourceCommand;
|
|
11
|
+
use Nevela\Laravel\Console\SeedCommand;
|
|
12
|
+
use Nevela\Laravel\Console\StatusCommand;
|
|
13
|
+
use Nevela\Laravel\Console\UpdateCommand;
|
|
14
|
+
use Nevela\Laravel\Console\UserCommand;
|
|
15
|
+
use Nevela\Laravel\Console\VersionCommand;
|
|
16
|
+
use Nevela\Laravel\Http\FlareErrors;
|
|
17
|
+
use Nevela\Laravel\Http\TokenController;
|
|
18
|
+
|
|
19
|
+
final class NevelaServiceProvider extends ServiceProvider
|
|
20
|
+
{
|
|
21
|
+
public function register(): void
|
|
22
|
+
{
|
|
23
|
+
$this->mergeConfigFrom(__DIR__.'/../config/nevela.php', 'nevela');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function boot(): void
|
|
27
|
+
{
|
|
28
|
+
if ($this->app->runningInConsole()) {
|
|
29
|
+
$this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
|
|
30
|
+
$this->commands([
|
|
31
|
+
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
32
|
+
UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class,
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$this->callAfterResolving(ExceptionHandler::class, function ($handler) {
|
|
37
|
+
if (method_exists($handler, 'renderable')) {
|
|
38
|
+
$handler->renderable(fn (\Throwable $e, $request) => FlareErrors::render($e, $request));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (! $this->app->routesAreCached()) {
|
|
43
|
+
$this->registerRoutes();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private function registerRoutes(): void
|
|
48
|
+
{
|
|
49
|
+
$prefix = config('nevela.prefix', 'api');
|
|
50
|
+
|
|
51
|
+
if (config('nevela.auth.enabled', true)) {
|
|
52
|
+
Route::prefix($prefix)->middleware('api')->name('nevela.auth.')->group(function () {
|
|
53
|
+
Route::post('auth/token', [TokenController::class, 'store'])->middleware('throttle:6,1')->name('store');
|
|
54
|
+
Route::middleware('auth:sanctum')->group(function () {
|
|
55
|
+
Route::get('auth/me', [TokenController::class, 'show'])->name('show');
|
|
56
|
+
Route::delete('auth/token', [TokenController::class, 'destroy'])->name('destroy');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Open to anyone, and says only that this is a Nevela app and which one. It is how the
|
|
62
|
+
// dashboard and `nevela:status` tell this app from another program on the same port.
|
|
63
|
+
Route::prefix($prefix)->middleware(['api', 'throttle:60,1'])->get('_nevela/ping', fn () => response()->json([
|
|
64
|
+
'nevela' => Nevela::VERSION,
|
|
65
|
+
'app' => Nevela::fingerprint(),
|
|
66
|
+
]))->name('nevela.ping');
|
|
67
|
+
|
|
68
|
+
Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
|
|
69
|
+
// The descriptors, so the web app (and tooling) can check it matches the API.
|
|
70
|
+
Route::get('_nevela/resources', fn () => response()->json([
|
|
71
|
+
'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
|
|
72
|
+
]))->name('resources');
|
|
73
|
+
|
|
74
|
+
if (is_file($routes = base_path('routes/nevela.php'))) {
|
|
75
|
+
require $routes;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Works out how to bring an app's dashboard up to a newer template without losing what
|
|
7
|
+
* the developer changed.
|
|
8
|
+
*
|
|
9
|
+
* Three versions of every file are compared: the template the app was created from
|
|
10
|
+
* (base), the newer template (next) and what is in the app now (yours). Only a file the
|
|
11
|
+
* developer hasn't touched is replaced. Pure on purpose: no filesystem, no network.
|
|
12
|
+
*/
|
|
13
|
+
final class DashboardUpdate
|
|
14
|
+
{
|
|
15
|
+
public const UPDATE = 'update'; // you didn't change it; the template did
|
|
16
|
+
public const ADD = 'add'; // new in the template
|
|
17
|
+
public const CONFLICT = 'conflict'; // you changed it and so did the template
|
|
18
|
+
public const REMOVED = 'removed'; // gone from the template; you still have it unchanged
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param array<string, string> $base path => contents, the template the app started from
|
|
22
|
+
* @param array<string, string> $next path => contents, the template to move to
|
|
23
|
+
* @param array<string, string|null> $yours path => contents in the app, null when the file is missing
|
|
24
|
+
* @return array<string, string> path => one of the constants above. Files needing nothing are left out.
|
|
25
|
+
*/
|
|
26
|
+
public static function plan(array $base, array $next, array $yours): array
|
|
27
|
+
{
|
|
28
|
+
$plan = [];
|
|
29
|
+
foreach ($next as $path => $contents) {
|
|
30
|
+
$mine = $yours[$path] ?? null;
|
|
31
|
+
$before = $base[$path] ?? null;
|
|
32
|
+
|
|
33
|
+
if ($mine !== null && self::same($mine, $contents)) {
|
|
34
|
+
continue; // already there
|
|
35
|
+
}
|
|
36
|
+
if ($mine === null) {
|
|
37
|
+
// Not in the app. Add it if it is new; leave it out if you deleted it.
|
|
38
|
+
if ($before === null) {
|
|
39
|
+
$plan[$path] = self::ADD;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if ($before !== null && self::same($before, $contents)) {
|
|
45
|
+
continue; // the template didn't change this file; your version stands
|
|
46
|
+
}
|
|
47
|
+
$plan[$path] = $before !== null && self::same($mine, $before) ? self::UPDATE : self::CONFLICT;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
foreach ($base as $path => $contents) {
|
|
51
|
+
$mine = $yours[$path] ?? null;
|
|
52
|
+
if (! isset($next[$path]) && $mine !== null && self::same($mine, $contents)) {
|
|
53
|
+
$plan[$path] = self::REMOVED;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ksort($plan);
|
|
58
|
+
|
|
59
|
+
return $plan;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Move the app's package.json to the template's dependency versions, keeping its name
|
|
64
|
+
* and anything else the developer added.
|
|
65
|
+
*
|
|
66
|
+
* A dependency is moved when the app still has the version the old template had, or
|
|
67
|
+
* doesn't have it at all. One the developer pinned differently is left and reported.
|
|
68
|
+
*
|
|
69
|
+
* @return array{json: string, changed: array<string, string>, kept: array<string, string>}
|
|
70
|
+
*/
|
|
71
|
+
public static function mergePackageJson(string $yours, string $base, string $next): array
|
|
72
|
+
{
|
|
73
|
+
$app = json_decode($yours, true, 512, JSON_THROW_ON_ERROR);
|
|
74
|
+
$old = json_decode($base, true, 512, JSON_THROW_ON_ERROR);
|
|
75
|
+
$new = json_decode($next, true, 512, JSON_THROW_ON_ERROR);
|
|
76
|
+
$changed = [];
|
|
77
|
+
$kept = [];
|
|
78
|
+
|
|
79
|
+
foreach (['dependencies', 'devDependencies'] as $section) {
|
|
80
|
+
foreach ($new[$section] ?? [] as $name => $version) {
|
|
81
|
+
$mine = $app[$section][$name] ?? null;
|
|
82
|
+
if ($mine === $version) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
$before = $old[$section][$name] ?? null;
|
|
86
|
+
if ($mine === null || $mine === $before) {
|
|
87
|
+
$app[$section][$name] = $version;
|
|
88
|
+
$changed[$name] = ($mine ?? 'not installed').' → '.$version;
|
|
89
|
+
} else {
|
|
90
|
+
$kept[$name] = "{$mine} (template: {$version})";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (isset($app[$section])) {
|
|
94
|
+
ksort($app[$section]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// npm's own formatting: two spaces, unescaped slashes, a trailing newline.
|
|
99
|
+
$json = json_encode($app, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
100
|
+
$json = preg_replace_callback('/^( {4})+/m', fn ($m) => str_repeat(' ', strlen($m[0]) / 4), $json);
|
|
101
|
+
|
|
102
|
+
return ['json' => $json."\n", 'changed' => $changed, 'kept' => $kept];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Equal apart from line endings: git on Windows may have rewritten them. */
|
|
106
|
+
public static function same(string $a, string $b): bool
|
|
107
|
+
{
|
|
108
|
+
return $a === $b || str_replace("\r\n", "\n", $a) === str_replace("\r\n", "\n", $b);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Http;
|
|
6
|
+
use Throwable;
|
|
7
|
+
|
|
8
|
+
/** What Packagist knows about nevela/laravel. */
|
|
9
|
+
final class Releases
|
|
10
|
+
{
|
|
11
|
+
private static string|null|false $latest = false;
|
|
12
|
+
|
|
13
|
+
/** The newest stable release, or null when Packagist can't be reached. Asked once per run. */
|
|
14
|
+
public static function latest(): ?string
|
|
15
|
+
{
|
|
16
|
+
if (self::$latest !== false) {
|
|
17
|
+
return self::$latest;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
$releases = Http::timeout(8)->get('https://repo.packagist.org/p2/nevela/laravel.json')->json('packages.nevela/laravel') ?? [];
|
|
21
|
+
} catch (Throwable) {
|
|
22
|
+
return self::$latest = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return self::$latest = self::newest(array_map(fn ($release) => (string) ($release['version'] ?? ''), $releases));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The highest x.y.z in a list of version names. Branches and pre-releases don't count.
|
|
30
|
+
*
|
|
31
|
+
* @param list<string> $names
|
|
32
|
+
*/
|
|
33
|
+
public static function newest(array $names): ?string
|
|
34
|
+
{
|
|
35
|
+
$versions = [];
|
|
36
|
+
foreach ($names as $name) {
|
|
37
|
+
if (preg_match('/^v?(\d+\.\d+\.\d+)$/', $name, $m)) {
|
|
38
|
+
$versions[] = $m[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
usort($versions, 'version_compare');
|
|
42
|
+
|
|
43
|
+
return $versions === [] ? null : end($versions);
|
|
44
|
+
}
|
|
45
|
+
}
|