create-nevela 0.1.3 → 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.
package/selfupdate.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // Always run the newest installer, whatever version the package manager handed over.
2
+ //
3
+ // pnpm skips versions published in the last few hours, and both pnpm and npm cache what
4
+ // `create` resolved to. Left alone, `pnpm create nevela` runs a stale installer for a while
5
+ // after every release. So the first thing any installer does is ask npm what the newest
6
+ // version is, and if that isn't this one, download it and hand over.
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import zlib from 'node:zlib';
13
+ import { isNewer } from './packagist.mjs';
14
+
15
+ const REGISTRY = 'https://registry.npmjs.org/create-nevela';
16
+
17
+ /** The newest create-nevela on npm when it is newer than `current`, else null. Never throws. */
18
+ export async function newerVersion(current) {
19
+ try {
20
+ const response = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(4000) });
21
+ const latest = (await response.json()).version;
22
+ return isNewer(latest, current) ? latest : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Unpack a .tar (already gunzipped) into `dir`. Enough of the format for an npm tarball:
30
+ * regular files and directories, with the long-name extensions npm uses.
31
+ */
32
+ export function untar(buffer, dir) {
33
+ const text = (start, length) => buffer.toString('utf8', start, start + length).replace(/\0.*$/s, '');
34
+ let offset = 0;
35
+ let longName = null;
36
+ while (offset + 512 <= buffer.length) {
37
+ const name = text(offset, 100);
38
+ if (name === '') break; // two empty blocks end the archive
39
+ const size = parseInt(text(offset + 124, 12).trim() || '0', 8);
40
+ const type = text(offset + 156, 1) || '0';
41
+ const prefix = text(offset + 345, 155);
42
+ const body = buffer.subarray(offset + 512, offset + 512 + size);
43
+ offset += 512 + Math.ceil(size / 512) * 512;
44
+
45
+ if (type === 'x') {
46
+ // pax header: "<length> path=<name>\n"
47
+ const match = /\d+ path=([^\n]*)\n/.exec(body.toString('utf8'));
48
+ if (match) longName = match[1];
49
+ continue;
50
+ }
51
+ if (type === 'L') {
52
+ longName = body.toString('utf8').replace(/\0.*$/s, '');
53
+ continue;
54
+ }
55
+ if (type === 'g') continue;
56
+
57
+ const entry = longName ?? (prefix ? `${prefix}/${name}` : name);
58
+ longName = null;
59
+ const target = path.resolve(dir, entry);
60
+ // Nothing in the archive may land outside the folder it is unpacked into.
61
+ if (target !== path.resolve(dir) && !target.startsWith(path.resolve(dir) + path.sep)) continue;
62
+ if (type === '5') fs.mkdirSync(target, { recursive: true });
63
+ else if (type === '0' || type === '') {
64
+ fs.mkdirSync(path.dirname(target), { recursive: true });
65
+ fs.writeFileSync(target, body);
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Download `version` from npm and run it with the same arguments. Returns its exit code,
72
+ * or null when it couldn't be fetched, in which case the caller carries on itself.
73
+ */
74
+ export async function runVersion(version, args) {
75
+ const dir = path.join(os.tmpdir(), `create-nevela-${version}-${process.pid}`);
76
+ try {
77
+ const response = await fetch(`${REGISTRY}/-/create-nevela-${version}.tgz`, { signal: AbortSignal.timeout(30000) });
78
+ if (!response.ok) return null;
79
+ untar(zlib.gunzipSync(Buffer.from(await response.arrayBuffer())), dir);
80
+ const entry = path.join(dir, 'package', 'index.mjs');
81
+ if (!fs.existsSync(entry)) return null;
82
+
83
+ const result = spawnSync(process.execPath, [entry, ...args], {
84
+ stdio: 'inherit',
85
+ // The newer one must not go looking for a newer one in turn.
86
+ env: { ...process.env, NEVELA_NO_SELF_UPDATE: '1' },
87
+ });
88
+ return result.status ?? 1;
89
+ } catch {
90
+ return null;
91
+ } finally {
92
+ fs.rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ }
package/shell.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // Running other programs, the same way on every platform.
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export const windows = process.platform === 'win32';
6
+
7
+ // `^` is not in the safe list on purpose: unquoted, cmd.exe treats it as an escape
8
+ // character and drops it, which turned the constraint "^0.1" into an exact "0.1".
9
+ export const quote = (arg) => (/^[\w./:=@,-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`);
10
+ export const shellLine = (command, args) => [command, ...args.map(quote)].join(' ');
11
+
12
+ /**
13
+ * .bat and .cmd shims (composer, pnpm, Herd's php) only resolve through a shell on
14
+ * Windows. That is also why `php` can be "not found" in Git Bash while this still finds
15
+ * it: Git Bash doesn't run php.bat for the bare name, and cmd.exe does. A shell takes one
16
+ * command string, so every argument is quoted here.
17
+ */
18
+ export function spawnCommand(command, args, options) {
19
+ if (!windows) return spawnSync(command, args, options);
20
+ return spawnSync(shellLine(command, args), { ...options, shell: true });
21
+ }
@@ -0,0 +1,55 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+
7
+ /**
8
+ * Everything a freshly installed app needs before it can be used, in one process.
9
+ *
10
+ * `create-nevela` runs this once. Done as separate artisan calls, each step pays for
11
+ * Laravel starting up again, which on Windows is a few seconds every time.
12
+ */
13
+ final class SetupCommand extends Command
14
+ {
15
+ protected $signature = 'nevela:setup
16
+ {--name= : Name for the starter account}
17
+ {--email= : Email for the starter account. Leave out to create no account}
18
+ {--password= : Password for the starter account}';
19
+
20
+ protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations and a starter account';
21
+
22
+ protected $hidden = true;
23
+
24
+ public function handle(): int
25
+ {
26
+ if (blank(config('app.key'))) {
27
+ $this->callSilently('key:generate', ['--force' => true]);
28
+ }
29
+ $this->callSilently('vendor:publish', ['--tag' => 'sanctum-migrations']);
30
+
31
+ // routes/nevela.php, the launcher, and the dashboard's (empty) resource registry.
32
+ if ($this->callSilently('nevela:generate') !== self::SUCCESS) {
33
+ $this->components->error('Could not write the generated files.');
34
+
35
+ return self::FAILURE;
36
+ }
37
+ if ($this->call('migrate', ['--force' => true]) !== self::SUCCESS) {
38
+ return self::FAILURE;
39
+ }
40
+
41
+ if ($this->option('email')) {
42
+ $status = $this->call('nevela:user', [
43
+ '--name' => $this->option('name') ?: 'Admin',
44
+ '--email' => $this->option('email'),
45
+ '--password' => $this->option('password'),
46
+ ]);
47
+ if ($status !== self::SUCCESS) {
48
+ // The app itself is fine; the installer says how to add an account by hand.
49
+ return 2;
50
+ }
51
+ }
52
+
53
+ return self::SUCCESS;
54
+ }
55
+ }
@@ -6,6 +6,7 @@ use Illuminate\Console\Command;
6
6
  use Illuminate\Support\Facades\DB;
7
7
  use Illuminate\Support\Facades\Http;
8
8
  use Nevela\Laravel\Nevela;
9
+ use Nevela\Laravel\Support\DashboardState;
9
10
  use Nevela\Laravel\Support\Releases;
10
11
  use Throwable;
11
12
 
@@ -92,8 +93,30 @@ final class StatusCommand extends Command
92
93
  if (! $web || ! is_dir($web)) {
93
94
  $row('Dashboard', '<fg=gray>not found at nevela.web_path</>');
94
95
  } else {
95
- $marker = json_decode((string) @file_get_contents(rtrim($web, '/\\').'/.nevela.json'), true);
96
- $row('Dashboard', is_array($marker) && isset($marker['template']) ? "template {$marker['template']}" : '<fg=gray>template version not recorded</>');
96
+ $state = DashboardState::read($web);
97
+ $row('Dashboard', $state->template !== null ? "template {$state->template}" : '<fg=gray>template version not recorded</>');
98
+
99
+ // What this app has made its own. An update leaves every one of these alone.
100
+ $changes = $state->yourChanges($web);
101
+ if ($changes === null) {
102
+ $row('Your dashboard changes', '<fg=gray>not tracked yet: php nevela update records them</>');
103
+ } else {
104
+ $count = count($changes['changed']) + count($changes['deleted']);
105
+ $row('Your dashboard changes', $count === 0 ? 'none' : "{$count} file(s), kept on every update");
106
+ if ($this->output->isVerbose()) {
107
+ foreach ($changes['changed'] as $path) {
108
+ $this->line(" <fg=gray>changed</> {$path}");
109
+ }
110
+ foreach ($changes['deleted'] as $path) {
111
+ $this->line(" <fg=gray>deleted</> {$path}");
112
+ }
113
+ } elseif ($count > 0) {
114
+ $this->line(' <fg=gray>php nevela status -v lists them</>');
115
+ }
116
+ }
117
+ if ($last = end($state->history)) {
118
+ $row('Last dashboard update', "{$last['from']} → {$last['to']} on ".substr((string) ($last['at'] ?? ''), 0, 10));
119
+ }
97
120
 
98
121
  // `php nevela dev` may have put the API on another port and told the dashboard so,
99
122
  // which is fine. Look for this app wherever it is running before judging.
@@ -3,11 +3,11 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
- use Illuminate\Support\Facades\Http;
7
6
  use Nevela\Laravel\Nevela;
7
+ use Nevela\Laravel\Support\DashboardState;
8
8
  use Nevela\Laravel\Support\DashboardUpdate;
9
9
  use Nevela\Laravel\Support\Releases;
10
- use PharData;
10
+ use Nevela\Laravel\Support\TemplateSource;
11
11
  use RecursiveDirectoryIterator;
12
12
  use RecursiveIteratorIterator;
13
13
  use Symfony\Component\Process\PhpExecutableFinder;
@@ -18,7 +18,8 @@ final class UpdateCommand extends Command
18
18
  {
19
19
  protected $signature = 'nevela:update
20
20
  {--check : Show what would change, and change nothing}
21
- {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}';
21
+ {--skip-package : Leave the nevela/laravel package as it is; update only the dashboard}
22
+ {--undo : Put the dashboard back as it was before the last update}';
22
23
 
23
24
  protected $description = 'Update Nevela: the package, the generated code and the dashboard';
24
25
 
@@ -27,6 +28,9 @@ final class UpdateCommand extends Command
27
28
 
28
29
  public function handle(): int
29
30
  {
31
+ if ($this->option('undo')) {
32
+ return $this->undo();
33
+ }
30
34
  $check = (bool) $this->option('check');
31
35
 
32
36
  if (! $this->option('skip-package')) {
@@ -127,58 +131,76 @@ final class UpdateCommand extends Command
127
131
 
128
132
  private function updateDashboard(bool $check): int
129
133
  {
130
- $web = config('nevela.web_path');
131
- if (! $web || ! is_dir($web)) {
134
+ $web = $this->webPath();
135
+ if ($web === null) {
132
136
  $this->components->info('No dashboard at nevela.web_path, so there is nothing more to update.');
133
137
 
134
138
  return self::SUCCESS;
135
139
  }
136
- $web = rtrim(str_replace('\\', '/', realpath($web) ?: $web), '/');
137
- $marker = "{$web}/.nevela.json";
138
- $from = is_file($marker) ? (json_decode((string) file_get_contents($marker), true)['template'] ?? self::FIRST_TRACKED) : self::FIRST_TRACKED;
140
+ $state = DashboardState::read($web);
141
+ $from = $state->template ?? self::FIRST_TRACKED;
139
142
  // In --check the package hasn't been updated, so look at the newest dashboard there is.
140
143
  $to = $check ? (Releases::latest() ?? Nevela::VERSION) : Nevela::VERSION;
141
144
 
142
145
  if (version_compare($from, $to, '>=')) {
143
146
  $this->components->info("The dashboard is on the latest template ({$from}).");
147
+ // An app from before fingerprints were kept: record them now, so the next update
148
+ // knows what was changed without having to work it out from a download.
149
+ if (! $check && $state->files === null) {
150
+ try {
151
+ $state->adopt($from, TemplateSource::fetch($from)['files']);
152
+ $state->write($web);
153
+ } catch (Throwable) {
154
+ // Not worth failing an up-to-date app over; the next update can still download it.
155
+ }
156
+ }
144
157
 
145
158
  return self::SUCCESS;
146
159
  }
147
160
 
148
161
  try {
149
- $base = $this->template($from);
150
- $next = $this->template($to);
162
+ $next = TemplateSource::fetch($to)['files'];
163
+ // What the app is on: its recorded fingerprints, or for an older app the old template itself.
164
+ if ($state->files === null) {
165
+ $state->adopt($from, TemplateSource::fetch($from)['files']);
166
+ }
151
167
  } catch (Throwable $e) {
152
- $this->components->error("Couldn't download the dashboard template ({$e->getMessage()}). The dashboard was left as it is; run this again later.");
168
+ $this->components->error("Couldn't get the dashboard template: {$e->getMessage()}. The dashboard was left as it is; run this again later.");
153
169
 
154
170
  return self::FAILURE;
155
171
  }
156
172
 
157
- $paths = array_unique([...array_keys($base), ...array_keys($next)]);
158
- $yours = [];
159
- foreach ($paths as $path) {
160
- $yours[$path] = is_file("{$web}/{$path}") ? (string) file_get_contents("{$web}/{$path}") : null;
161
- }
173
+ $base = $state->files;
174
+ $nextHashes = DashboardUpdate::hashes($next);
175
+ $yours = DashboardState::fingerprints($web, array_unique([...array_keys($base), ...array_keys($next)]));
162
176
 
163
177
  // package.json is merged, not replaced: it has your app's name and your own dependencies.
164
178
  $packages = null;
165
- if (isset($base['package.json'], $next['package.json'], $yours['package.json'])) {
166
- $packages = DashboardUpdate::mergePackageJson($yours['package.json'], $base['package.json'], $next['package.json']);
179
+ if (isset($next['package.json']) && is_file("{$web}/package.json")) {
180
+ $packages = DashboardUpdate::mergePackageJson((string) file_get_contents("{$web}/package.json"), $state->packageJson(), $next['package.json']);
167
181
  }
168
- unset($base['package.json'], $next['package.json']);
182
+ unset($base['package.json'], $nextHashes['package.json'], $yours['package.json']);
169
183
 
170
- $plan = DashboardUpdate::plan($base, $next, $yours);
184
+ $plan = DashboardUpdate::plan($base, $nextHashes, $yours);
171
185
  $this->components->info(($check ? 'Dashboard: what would change, ' : 'Dashboard: ')."template {$from} → {$to}");
172
186
 
173
- $counts = [DashboardUpdate::UPDATE => 0, DashboardUpdate::ADD => 0, DashboardUpdate::CONFLICT => 0, DashboardUpdate::REMOVED => 0];
187
+ $done = [DashboardUpdate::UPDATE => [], DashboardUpdate::ADD => [], DashboardUpdate::CONFLICT => [], DashboardUpdate::REMOVED => []];
188
+ $backup = '.nevela/backups/'.gmdate('Ymd-His')."-{$from}-to-{$to}";
189
+ $incoming = ".nevela/incoming/{$to}";
190
+
174
191
  foreach ($plan as $path => $action) {
175
- $counts[$action]++;
176
- if (! $check && ($action === DashboardUpdate::UPDATE || $action === DashboardUpdate::ADD)) {
177
- $target = "{$web}/{$path}";
178
- if (! is_dir(dirname($target))) {
179
- mkdir(dirname($target), 0775, true);
192
+ $done[$action][] = $path;
193
+ if (! $check) {
194
+ if ($action === DashboardUpdate::UPDATE) {
195
+ // Untouched by you, so identical to the old template. Kept anyway: a copy costs nothing.
196
+ $this->put("{$web}/{$backup}/{$path}", (string) file_get_contents("{$web}/{$path}"));
197
+ $this->put("{$web}/{$path}", $next[$path]);
198
+ } elseif ($action === DashboardUpdate::ADD) {
199
+ $this->put("{$web}/{$path}", $next[$path]);
200
+ } elseif ($action === DashboardUpdate::CONFLICT) {
201
+ // Yours stays. The new version is put where you can compare the two.
202
+ $this->put("{$web}/{$incoming}/{$path}", $next[$path]);
180
203
  }
181
- file_put_contents($target, $next[$path]);
182
204
  }
183
205
  $this->components->twoColumnDetail($path, match ($action) {
184
206
  DashboardUpdate::UPDATE => $check ? '<fg=blue>would update</>' : '<fg=blue>updated</>',
@@ -188,84 +210,137 @@ final class UpdateCommand extends Command
188
210
  });
189
211
  }
190
212
 
191
- if ($packages && $packages['changed']) {
192
- foreach ($packages['changed'] as $name => $change) {
193
- $this->components->twoColumnDetail("package.json: {$name}", '<fg=blue>'.$change.'</>');
194
- }
195
- if (! $check) {
196
- file_put_contents("{$web}/package.json", $packages['json']);
197
- }
213
+ foreach ($packages['changed'] ?? [] as $name => $change) {
214
+ $this->components->twoColumnDetail("package.json: {$name}", '<fg=blue>'.$change.'</>');
198
215
  }
199
216
  foreach ($packages['kept'] ?? [] as $name => $versions) {
200
217
  $this->components->twoColumnDetail("package.json: {$name}", "<fg=yellow>kept yours, {$versions}</>");
201
218
  }
202
-
203
219
  if ($plan === [] && ! ($packages['changed'] ?? [])) {
204
220
  $this->line(' Nothing in the dashboard needed changing.');
205
221
  }
206
222
  $this->newLine();
207
223
 
208
224
  if (! $check) {
209
- file_put_contents($marker, json_encode(['template' => $to], JSON_PRETTY_PRINT)."\n");
225
+ $touched = $done[DashboardUpdate::UPDATE] !== [] || $done[DashboardUpdate::ADD] !== [] || ($packages['changed'] ?? []) !== [];
226
+ if ($packages && $packages['changed']) {
227
+ $this->put("{$web}/{$backup}/package.json", (string) file_get_contents("{$web}/package.json"));
228
+ file_put_contents("{$web}/package.json", $packages['json']);
229
+ }
230
+ if ($touched) {
231
+ // The record as it was, so `--undo` can put that back too.
232
+ $this->put("{$web}/{$backup}/.nevela.json", (string) @file_get_contents(DashboardState::path($web)) ?: '{}');
233
+ }
234
+ // .nevela/ holds copies, not source: it keeps itself out of git.
235
+ if (is_dir("{$web}/.nevela") && ! is_file("{$web}/.nevela/.gitignore")) {
236
+ file_put_contents("{$web}/.nevela/.gitignore", "*\n");
237
+ }
238
+
239
+ $state->adopt($to, $next);
240
+ $state->history[] = array_filter([
241
+ 'at' => gmdate('c'),
242
+ 'from' => $from,
243
+ 'to' => $to,
244
+ 'updated' => $done[DashboardUpdate::UPDATE],
245
+ 'added' => $done[DashboardUpdate::ADD],
246
+ 'kept' => $done[DashboardUpdate::CONFLICT],
247
+ 'removed' => $done[DashboardUpdate::REMOVED],
248
+ 'dependencies' => $packages['changed'] ?? [],
249
+ 'backup' => $touched ? $backup : null,
250
+ ]);
251
+ $state->write($web);
252
+
253
+ if ($touched) {
254
+ $this->line(" Every file this replaced was copied to <fg=cyan>{$this->relative($web)}/{$backup}</> first. To put it all back: <fg=cyan>php nevela update --undo</>");
255
+ }
210
256
  }
211
- if ($counts[DashboardUpdate::CONFLICT] > 0) {
212
- $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");
257
+ if ($done[DashboardUpdate::CONFLICT] !== []) {
258
+ $count = count($done[DashboardUpdate::CONFLICT]);
259
+ $this->components->warn("{$count} file(s) changed in the template and in your app. Yours were kept.".($check ? '' : " The template's new versions are in {$this->relative($web)}/{$incoming} for you to compare."));
213
260
  }
214
- if ($counts[DashboardUpdate::REMOVED] > 0) {
215
- $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.");
261
+ if ($done[DashboardUpdate::REMOVED] !== []) {
262
+ $this->line(' '.count($done[DashboardUpdate::REMOVED])." file(s) are no longer part of the template. They were left in place; delete them if you don't use them.");
216
263
  }
217
264
  if (! $check && ($packages['changed'] ?? [])) {
218
- $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && pnpm install</> (or npm install).');
265
+ $this->line(' The dashboard\'s dependencies changed. Install them: <fg=cyan>cd '.$this->relative($web).' && '.DevCommand::packageManager($web).' install</>');
219
266
  }
220
267
 
221
268
  return self::SUCCESS;
222
269
  }
223
270
 
224
- /**
225
- * The dashboard as it shipped in a version: path => contents.
226
- *
227
- * It comes from the create-nevela package on npm, which carries the dashboard and is
228
- * released with the same version number as this package.
229
- *
230
- * @return array<string, string>
231
- */
232
- private function template(string $version): array
271
+ /** Put the dashboard back as it was before the last update that changed it. */
272
+ private function undo(): int
233
273
  {
234
- $dir = rtrim(sys_get_temp_dir(), '/\\').DIRECTORY_SEPARATOR.'nevela-template-'.$version.'-'.bin2hex(random_bytes(4));
235
- mkdir($dir, 0775, true);
236
- $archive = $dir.DIRECTORY_SEPARATOR.'template.tgz';
274
+ $web = $this->webPath();
275
+ if ($web === null) {
276
+ $this->components->error('No dashboard at nevela.web_path.');
237
277
 
238
- try {
239
- // NEVELA_TEMPLATE_DIR: a folder of create-nevela-<version>.tgz files to use instead
240
- // of npm. For working on Nevela itself, and for updating without a connection.
241
- $local = rtrim((string) env('NEVELA_TEMPLATE_DIR', ''), '/\\');
242
- if ($local !== '' && is_file("{$local}/create-nevela-{$version}.tgz")) {
243
- copy("{$local}/create-nevela-{$version}.tgz", $archive);
244
- } else {
245
- $response = Http::timeout(60)->get("https://registry.npmjs.org/create-nevela/-/create-nevela-{$version}.tgz");
246
- if (! $response->successful()) {
247
- throw new \RuntimeException("create-nevela {$version} is not on npm (HTTP {$response->status()})");
248
- }
249
- file_put_contents($archive, $response->body());
278
+ return self::FAILURE;
279
+ }
280
+ $state = DashboardState::read($web);
281
+ $last = null;
282
+ foreach (array_reverse($state->history) as $entry) {
283
+ if (isset($entry['backup']) && is_dir("{$web}/{$entry['backup']}")) {
284
+ $last = $entry;
285
+ break;
250
286
  }
251
- (new PharData($archive))->extractTo($dir, null, true);
287
+ }
288
+ if ($last === null) {
289
+ $this->components->info('There is no update to undo: no backup was found.');
252
290
 
253
- $root = $dir.DIRECTORY_SEPARATOR.'package'.DIRECTORY_SEPARATOR.'template'.DIRECTORY_SEPARATOR.'web';
254
- if (! is_dir($root)) {
255
- throw new \RuntimeException("create-nevela {$version} has no dashboard template in it");
291
+ return self::SUCCESS;
292
+ }
293
+
294
+ $dir = "{$web}/{$last['backup']}";
295
+ $restored = 0;
296
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
297
+ $path = str_replace('\\', '/', substr($file->getPathname(), strlen($dir) + 1));
298
+ if ($path === '.nevela.json') {
299
+ continue;
256
300
  }
257
- $files = [];
258
- foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
259
- $path = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
260
- // npm can't carry .gitignore files, so the template holds them as _gitignore.
261
- $path = preg_replace('#(^|/)_gitignore$#', '$1.gitignore', $path);
262
- $files[$path] = (string) file_get_contents($file->getPathname());
301
+ $this->put("{$web}/{$path}", (string) file_get_contents($file->getPathname()));
302
+ $this->components->twoColumnDetail($path, '<fg=blue>restored</>');
303
+ $restored++;
304
+ }
305
+ // Files the update added are taken out again, unless you have changed them since.
306
+ foreach ($last['added'] ?? [] as $path) {
307
+ $recorded = $state->files[$path] ?? null;
308
+ if (is_file("{$web}/{$path}") && $recorded !== null && DashboardUpdate::hash((string) file_get_contents("{$web}/{$path}")) === $recorded) {
309
+ unlink("{$web}/{$path}");
310
+ $this->components->twoColumnDetail($path, '<fg=gray>removed (the update had added it)</>');
311
+ } elseif (is_file("{$web}/{$path}")) {
312
+ $this->components->twoColumnDetail($path, '<fg=yellow>kept: you changed it after the update</>');
263
313
  }
314
+ }
315
+ // The record goes back too, so the same update can be run again later.
316
+ if (is_file("{$dir}/.nevela.json")) {
317
+ copy("{$dir}/.nevela.json", DashboardState::path($web));
318
+ }
319
+
320
+ $this->newLine();
321
+ $this->components->info("Put back {$restored} file(s). The dashboard is as it was before the update from {$last['from']} to {$last['to']}.");
322
+ $this->line(' The nevela/laravel package was not changed by this. To go back a version there too: <fg=cyan>composer require nevela/laravel:'.$last['from'].'</>');
264
323
 
265
- return $files;
266
- } finally {
267
- $this->removeDirectory($dir);
324
+ return self::SUCCESS;
325
+ }
326
+
327
+ /** The dashboard's folder, with forward slashes, or null when there isn't one. */
328
+ private function webPath(): ?string
329
+ {
330
+ $web = config('nevela.web_path');
331
+ if (! $web || ! is_dir($web)) {
332
+ return null;
268
333
  }
334
+
335
+ return rtrim(str_replace('\\', '/', realpath($web) ?: $web), '/');
336
+ }
337
+
338
+ private function put(string $file, string $contents): void
339
+ {
340
+ if (! is_dir(dirname($file))) {
341
+ mkdir(dirname($file), 0775, true);
342
+ }
343
+ file_put_contents($file, $contents);
269
344
  }
270
345
 
271
346
  /** The folder Composer installs the package from, when it isn't coming from Packagist. */
@@ -281,23 +356,16 @@ final class UpdateCommand extends Command
281
356
  return null;
282
357
  }
283
358
 
359
+ /** A path as someone at the top of the project would type it: apps/web/… */
284
360
  private function relative(string $path): string
285
361
  {
286
- $base = rtrim(str_replace('\\', '/', base_path()), '/');
287
- $parent = dirname($base);
362
+ $root = GenerateCommand::projectRoot();
363
+ $root = $root === null ? null : rtrim(str_replace('\\', '/', $root), '/');
364
+ if ($root !== null && str_starts_with($path, $root.'/')) {
365
+ return substr($path, strlen($root) + 1);
366
+ }
367
+ $parent = dirname(rtrim(str_replace('\\', '/', base_path()), '/'));
288
368
 
289
369
  return str_starts_with($path, $parent.'/') ? '../'.substr($path, strlen($parent) + 1) : $path;
290
370
  }
291
-
292
- private function removeDirectory(string $dir): void
293
- {
294
- if (! is_dir($dir)) {
295
- return;
296
- }
297
- $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
298
- foreach ($items as $item) {
299
- $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
300
- }
301
- @rmdir($dir);
302
- }
303
371
  }
@@ -17,7 +17,7 @@ use Nevela\Laravel\Support\Naming;
17
17
  final class Nevela
18
18
  {
19
19
  /** Written by `pnpm release`; the same number as the repository's git tag. */
20
- public const VERSION = '0.1.3';
20
+ public const VERSION = '0.1.4';
21
21
 
22
22
  /** @var array<string, Descriptor> */
23
23
  private static array $descriptors = [];
@@ -9,6 +9,7 @@ use Nevela\Laravel\Console\DevCommand;
9
9
  use Nevela\Laravel\Console\GenerateCommand;
10
10
  use Nevela\Laravel\Console\MakeResourceCommand;
11
11
  use Nevela\Laravel\Console\SeedCommand;
12
+ use Nevela\Laravel\Console\SetupCommand;
12
13
  use Nevela\Laravel\Console\StatusCommand;
13
14
  use Nevela\Laravel\Console\UpdateCommand;
14
15
  use Nevela\Laravel\Console\UserCommand;
@@ -29,7 +30,7 @@ final class NevelaServiceProvider extends ServiceProvider
29
30
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
30
31
  $this->commands([
31
32
  MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
32
- UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class,
33
+ UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
33
34
  ]);
34
35
  }
35
36