create-nevela 0.1.2 → 0.1.4

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,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.2';
21
-
22
- /** @var array<string, Descriptor> */
23
- private static array $descriptors = [];
24
-
25
- /** A resource's descriptor, from config('nevela.descriptors_path')/<kebab-name>.json. */
26
- public static function resource(string $name): Descriptor
27
- {
28
- if (isset(self::$descriptors[$name])) {
29
- return self::$descriptors[$name];
30
- }
31
- $path = rtrim(config('nevela.descriptors_path'), '/\\').DIRECTORY_SEPARATOR.Naming::kebab($name).'.json';
32
- if (! is_file($path)) {
33
- throw new InvalidArgumentException("No Nevela descriptor for {$name} at {$path}.");
34
- }
35
-
36
- return self::$descriptors[$name] = Descriptor::fromJson((string) file_get_contents($path));
37
- }
38
-
39
- /** @return list<Descriptor> */
40
- public static function all(): array
41
- {
42
- $dir = config('nevela.descriptors_path');
43
- $all = [];
44
- foreach (glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.json') ?: [] as $file) {
45
- $d = Descriptor::fromJson((string) file_get_contents($file));
46
- $all[] = self::$descriptors[$d->name] = $d;
47
- }
48
-
49
- return $all;
50
- }
51
-
52
- public static function forget(): void
53
- {
54
- self::$descriptors = [];
55
- }
56
-
57
- /**
58
- * GET /{slug}: page, perPage, sort, q, filter[field] → { data, meta }.
59
- *
60
- * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
61
- */
62
- public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
63
- {
64
- $resource = self::resource($name);
65
- $list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
66
- if (! $list->ok()) {
67
- return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
68
- }
69
-
70
- if ($list->q !== null) {
71
- $columns = array_map(fn ($f) => $f->column(), array_values(array_filter($resource->fields, fn ($f) => $f->searchable())));
72
- if ($columns !== []) {
73
- $pattern = '%'.addcslashes($list->q, '%_\\').'%';
74
- $query->where(function (Builder $where) use ($columns, $pattern) {
75
- foreach ($columns as $column) {
76
- $where->orWhereLike($column, $pattern);
77
- }
78
- });
79
- }
80
- }
81
-
82
- foreach ($list->filters as $filter) {
83
- $filter['value'] === null
84
- ? $query->whereNull($filter['column'])
85
- : $query->where($filter['column'], $filter['value']);
86
- }
87
-
88
- $query->reorder()
89
- ->orderBy($list->sort['column'], $list->sort['direction'])
90
- ->orderBy($query->getModel()->getKeyName(), $list->sort['direction']);
91
-
92
- $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
93
-
94
- return response()->json([
95
- 'data' => $resourceClass::collection($page->getCollection())->resolve($request),
96
- 'meta' => [
97
- 'page' => $page->currentPage(),
98
- 'perPage' => $page->perPage(),
99
- 'total' => $page->total(),
100
- 'totalPages' => max(1, $page->lastPage()),
101
- ],
102
- ]);
103
- }
104
-
105
- /**
106
- * GET /{slug}/_stats?field=status&days=7 → { total, current, previous, values }.
107
- * What Flare's ResourceStats card shows above a table.
108
- */
109
- public static function stats(Builder $query, string $name, Request $request): JsonResponse
110
- {
111
- $resource = self::resource($name);
112
- $days = filter_var($request->query('days', 7), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 365]]);
113
- if ($days === false) {
114
- return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'days', 'message' => 'Must be between 1 and 365.']]], 400);
115
- }
116
- $key = $request->query('field');
117
- $field = is_string($key) ? ($resource->fields[$key] ?? null) : null;
118
- if ($key !== null && ($field === null || ! in_array($field->kind, ['enum', 'boolean'], true))) {
119
- return response()->json(['error' => 'Invalid query.', 'issues' => [['param' => 'field', 'message' => 'Must be an enum or boolean field.']]], 400);
120
- }
121
-
122
- $now = now();
123
- $since = $now->copy()->subDays($days);
124
- $before = $now->copy()->subDays($days * 2);
125
- $base = fn () => (clone $query)->reorder();
126
-
127
- $values = [];
128
- if ($field !== null) {
129
- $column = $field->column();
130
- foreach ($base()->toBase()->select($column.' as value')->selectRaw('count(*) as aggregate')->groupBy($column)->get() as $row) {
131
- $value = $field->kind === 'boolean' ? ($row->value ? 'true' : 'false') : (string) $row->value;
132
- $values[$value] = (int) $row->aggregate;
133
- }
134
- }
135
-
136
- return response()->json([
137
- 'total' => $base()->count(),
138
- 'current' => $base()->where('created_at', '>=', $since)->count(),
139
- 'previous' => $base()->where('created_at', '>=', $before)->where('created_at', '<', $since)->count(),
140
- 'values' => (object) $values,
141
- ]);
142
- }
143
-
144
- /** Whether a request is one Nevela answers (so errors use Flare's shape). */
145
- public static function handles(Request $request): bool
146
- {
147
- $prefix = trim((string) config('nevela.prefix', 'api'), '/');
148
-
149
- return $prefix === '' || $request->is($prefix, $prefix.'/*');
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.4';
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
+ }
@@ -5,11 +5,15 @@ namespace Nevela\Laravel;
5
5
  use Illuminate\Contracts\Debug\ExceptionHandler;
6
6
  use Illuminate\Support\Facades\Route;
7
7
  use Illuminate\Support\ServiceProvider;
8
+ use Nevela\Laravel\Console\DevCommand;
8
9
  use Nevela\Laravel\Console\GenerateCommand;
9
10
  use Nevela\Laravel\Console\MakeResourceCommand;
10
11
  use Nevela\Laravel\Console\SeedCommand;
12
+ use Nevela\Laravel\Console\SetupCommand;
13
+ use Nevela\Laravel\Console\StatusCommand;
11
14
  use Nevela\Laravel\Console\UpdateCommand;
12
15
  use Nevela\Laravel\Console\UserCommand;
16
+ use Nevela\Laravel\Console\VersionCommand;
13
17
  use Nevela\Laravel\Http\FlareErrors;
14
18
  use Nevela\Laravel\Http\TokenController;
15
19
 
@@ -24,7 +28,10 @@ final class NevelaServiceProvider extends ServiceProvider
24
28
  {
25
29
  if ($this->app->runningInConsole()) {
26
30
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
27
- $this->commands([MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class, UpdateCommand::class]);
31
+ $this->commands([
32
+ MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
33
+ UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
34
+ ]);
28
35
  }
29
36
 
30
37
  $this->callAfterResolving(ExceptionHandler::class, function ($handler) {
@@ -52,6 +59,13 @@ final class NevelaServiceProvider extends ServiceProvider
52
59
  });
53
60
  }
54
61
 
62
+ // Open to anyone, and says only that this is a Nevela app and which one. It is how the
63
+ // dashboard and `nevela:status` tell this app from another program on the same port.
64
+ Route::prefix($prefix)->middleware(['api', 'throttle:60,1'])->get('_nevela/ping', fn () => response()->json([
65
+ 'nevela' => Nevela::VERSION,
66
+ 'app' => Nevela::fingerprint(),
67
+ ]))->name('nevela.ping');
68
+
55
69
  Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
56
70
  // The descriptors, so the web app (and tooling) can check it matches the API.
57
71
  Route::get('_nevela/resources', fn () => response()->json([
@@ -0,0 +1,118 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ /**
6
+ * What an app remembers about its dashboard, in apps/web/.nevela.json.
7
+ *
8
+ * - `template`: the version of the dashboard the app is on.
9
+ * - `files`: a fingerprint of every template file at that version. Comparing the app's
10
+ * files against these is how an update tells "you changed this" from "you didn't",
11
+ * with nothing to download and nothing to guess.
12
+ * - `dependencies`: the template's package.json dependencies at that version, for the
13
+ * same question about each dependency.
14
+ * - `history`: what each update did.
15
+ *
16
+ * Commit the file. Backups and incoming files live beside it in .nevela/, which ignores
17
+ * itself.
18
+ */
19
+ final class DashboardState
20
+ {
21
+ /** Files the installer personalises for every app (its name); never "changed by you". */
22
+ public const PERSONALISED = ['package.json', 'lib/site.ts'];
23
+
24
+ /** @param array<string, string>|null $files @param array<string, array<string, string>>|null $dependencies @param list<array<string, mixed>> $history */
25
+ public function __construct(
26
+ public ?string $template = null,
27
+ public ?array $files = null,
28
+ public ?array $dependencies = null,
29
+ public array $history = [],
30
+ ) {}
31
+
32
+ public static function path(string $web): string
33
+ {
34
+ return rtrim($web, '/\\').'/.nevela.json';
35
+ }
36
+
37
+ public static function read(string $web): self
38
+ {
39
+ $data = json_decode((string) @file_get_contents(self::path($web)), true);
40
+ if (! is_array($data)) {
41
+ return new self;
42
+ }
43
+
44
+ return new self(
45
+ isset($data['template']) ? (string) $data['template'] : null,
46
+ isset($data['files']) && is_array($data['files']) ? $data['files'] : null,
47
+ isset($data['dependencies']) && is_array($data['dependencies']) ? $data['dependencies'] : null,
48
+ isset($data['history']) && is_array($data['history']) ? array_values($data['history']) : [],
49
+ );
50
+ }
51
+
52
+ public function write(string $web): void
53
+ {
54
+ $files = $this->files ?? [];
55
+ ksort($files);
56
+ file_put_contents(self::path($web), json_encode(array_filter([
57
+ 'template' => $this->template,
58
+ 'files' => $files ?: null,
59
+ 'dependencies' => $this->dependencies,
60
+ 'history' => $this->history ?: null,
61
+ ], fn ($value) => $value !== null), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
62
+ }
63
+
64
+ /**
65
+ * Record a template as the one this app is now on.
66
+ *
67
+ * @param array<string, string> $templateFiles path => contents
68
+ */
69
+ public function adopt(string $version, array $templateFiles): void
70
+ {
71
+ $this->template = $version;
72
+ $this->files = DashboardUpdate::hashes($templateFiles);
73
+ $package = json_decode($templateFiles['package.json'] ?? '{}', true) ?: [];
74
+ $this->dependencies = array_filter([
75
+ 'dependencies' => $package['dependencies'] ?? null,
76
+ 'devDependencies' => $package['devDependencies'] ?? null,
77
+ ]);
78
+ }
79
+
80
+ /** The old template's package.json, as far as merging needs it, or null when it wasn't recorded. */
81
+ public function packageJson(): ?string
82
+ {
83
+ return $this->dependencies === null ? null : json_encode($this->dependencies);
84
+ }
85
+
86
+ /**
87
+ * The app's own fingerprints for a set of paths.
88
+ *
89
+ * @param iterable<string> $paths
90
+ * @return array<string, string|null>
91
+ */
92
+ public static function fingerprints(string $web, iterable $paths): array
93
+ {
94
+ $web = rtrim($web, '/\\');
95
+ $yours = [];
96
+ foreach ($paths as $path) {
97
+ $yours[$path] = is_file("{$web}/{$path}") ? DashboardUpdate::hash((string) file_get_contents("{$web}/{$path}")) : null;
98
+ }
99
+
100
+ return $yours;
101
+ }
102
+
103
+ /**
104
+ * The dashboard files this app has made its own, leaving out the two the installer
105
+ * personalises for everyone.
106
+ *
107
+ * @return array{changed: list<string>, deleted: list<string>}|null null when no fingerprints were recorded
108
+ */
109
+ public function yourChanges(string $web): ?array
110
+ {
111
+ if ($this->files === null) {
112
+ return null;
113
+ }
114
+ $base = array_diff_key($this->files, array_flip(self::PERSONALISED));
115
+
116
+ return DashboardUpdate::changes($base, self::fingerprints($web, array_keys($base)));
117
+ }
118
+ }
@@ -6,7 +6,7 @@ namespace Nevela\Laravel\Support;
6
6
  * Works out how to bring an app's dashboard up to a newer template without losing what
7
7
  * the developer changed.
8
8
  *
9
- * Three versions of every file are compared: the template the app was created from
9
+ * Three versions of every file are compared, by fingerprint: the template the app is on
10
10
  * (base), the newer template (next) and what is in the app now (yours). Only a file the
11
11
  * developer hasn't touched is replaced. Pure on purpose: no filesystem, no network.
12
12
  */
@@ -18,19 +18,37 @@ final class DashboardUpdate
18
18
  public const REMOVED = 'removed'; // gone from the template; you still have it unchanged
19
19
 
20
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
21
+ * A file's fingerprint. Line endings are ignored: git on Windows may have rewritten
22
+ * them, and that is not a change anyone made.
23
+ */
24
+ public static function hash(string $contents): string
25
+ {
26
+ return sha1(str_replace("\r\n", "\n", $contents));
27
+ }
28
+
29
+ /**
30
+ * @param array<string, string> $contents path => contents
31
+ * @return array<string, string> path => fingerprint
32
+ */
33
+ public static function hashes(array $contents): array
34
+ {
35
+ return array_map(self::hash(...), $contents);
36
+ }
37
+
38
+ /**
39
+ * @param array<string, string> $base path => fingerprint, the template the app is on
40
+ * @param array<string, string> $next path => fingerprint, the template to move to
41
+ * @param array<string, string|null> $yours path => fingerprint of the app's file, null when it is missing
24
42
  * @return array<string, string> path => one of the constants above. Files needing nothing are left out.
25
43
  */
26
44
  public static function plan(array $base, array $next, array $yours): array
27
45
  {
28
46
  $plan = [];
29
- foreach ($next as $path => $contents) {
47
+ foreach ($next as $path => $wanted) {
30
48
  $mine = $yours[$path] ?? null;
31
49
  $before = $base[$path] ?? null;
32
50
 
33
- if ($mine !== null && self::same($mine, $contents)) {
51
+ if ($mine === $wanted) {
34
52
  continue; // already there
35
53
  }
36
54
  if ($mine === null) {
@@ -41,15 +59,14 @@ final class DashboardUpdate
41
59
 
42
60
  continue;
43
61
  }
44
- if ($before !== null && self::same($before, $contents)) {
62
+ if ($before === $wanted) {
45
63
  continue; // the template didn't change this file; your version stands
46
64
  }
47
- $plan[$path] = $before !== null && self::same($mine, $before) ? self::UPDATE : self::CONFLICT;
65
+ $plan[$path] = $mine === $before ? self::UPDATE : self::CONFLICT;
48
66
  }
49
67
 
50
- foreach ($base as $path => $contents) {
51
- $mine = $yours[$path] ?? null;
52
- if (! isset($next[$path]) && $mine !== null && self::same($mine, $contents)) {
68
+ foreach ($base as $path => $before) {
69
+ if (! isset($next[$path]) && ($yours[$path] ?? null) === $before) {
53
70
  $plan[$path] = self::REMOVED;
54
71
  }
55
72
  }
@@ -59,19 +76,45 @@ final class DashboardUpdate
59
76
  return $plan;
60
77
  }
61
78
 
79
+ /**
80
+ * The files of the template that differ in the app: what the developer has made their own.
81
+ *
82
+ * @param array<string, string> $base path => fingerprint, the template the app is on
83
+ * @param array<string, string|null> $yours path => fingerprint of the app's file, null when it is missing
84
+ * @return array{changed: list<string>, deleted: list<string>}
85
+ */
86
+ public static function changes(array $base, array $yours): array
87
+ {
88
+ $changed = [];
89
+ $deleted = [];
90
+ foreach ($base as $path => $before) {
91
+ $mine = $yours[$path] ?? null;
92
+ if ($mine === null) {
93
+ $deleted[] = $path;
94
+ } elseif ($mine !== $before) {
95
+ $changed[] = $path;
96
+ }
97
+ }
98
+ sort($changed);
99
+ sort($deleted);
100
+
101
+ return ['changed' => $changed, 'deleted' => $deleted];
102
+ }
103
+
62
104
  /**
63
105
  * Move the app's package.json to the template's dependency versions, keeping its name
64
106
  * and anything else the developer added.
65
107
  *
66
108
  * A dependency is moved when the app still has the version the old template had, or
67
109
  * doesn't have it at all. One the developer pinned differently is left and reported.
110
+ * Without the old template (`$base` null) only missing dependencies are added.
68
111
  *
69
112
  * @return array{json: string, changed: array<string, string>, kept: array<string, string>}
70
113
  */
71
- public static function mergePackageJson(string $yours, string $base, string $next): array
114
+ public static function mergePackageJson(string $yours, ?string $base, string $next): array
72
115
  {
73
116
  $app = json_decode($yours, true, 512, JSON_THROW_ON_ERROR);
74
- $old = json_decode($base, true, 512, JSON_THROW_ON_ERROR);
117
+ $old = $base === null ? null : json_decode($base, true, 512, JSON_THROW_ON_ERROR);
75
118
  $new = json_decode($next, true, 512, JSON_THROW_ON_ERROR);
76
119
  $changed = [];
77
120
  $kept = [];
@@ -83,7 +126,7 @@ final class DashboardUpdate
83
126
  continue;
84
127
  }
85
128
  $before = $old[$section][$name] ?? null;
86
- if ($mine === null || $mine === $before) {
129
+ if ($mine === null || ($old !== null && $mine === $before)) {
87
130
  $app[$section][$name] = $version;
88
131
  $changed[$name] = ($mine ?? 'not installed').' → '.$version;
89
132
  } else {
@@ -101,10 +144,4 @@ final class DashboardUpdate
101
144
 
102
145
  return ['json' => $json."\n", 'changed' => $changed, 'kept' => $kept];
103
146
  }
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
147
  }