create-nevela 0.1.3 → 0.1.5
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 +10 -1
- package/index.mjs +184 -68
- package/package.json +41 -37
- package/packagist.mjs +17 -0
- package/project.mjs +126 -0
- package/selfupdate.mjs +94 -0
- package/shell.mjs +21 -0
- package/template/laravel/src/Console/SetupCommand.php +55 -0
- package/template/laravel/src/Console/StatusCommand.php +25 -2
- package/template/laravel/src/Console/UpdateCommand.php +160 -92
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +2 -1
- package/template/laravel/src/Support/DashboardState.php +118 -0
- package/template/laravel/src/Support/DashboardUpdate.php +57 -20
- package/template/laravel/src/Support/TemplateSource.php +144 -0
- package/template/web/components/dashboard/dashboard-header.tsx +24 -2
- package/template/web/package.json +2 -2
- package/template.mjs +127 -94
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
|
-
$
|
|
96
|
-
$row('Dashboard',
|
|
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
|
|
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 =
|
|
131
|
-
if (
|
|
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
|
-
$
|
|
137
|
-
$
|
|
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
|
-
$
|
|
150
|
-
|
|
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
|
|
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
|
-
$
|
|
158
|
-
$
|
|
159
|
-
|
|
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($
|
|
166
|
-
$packages = DashboardUpdate::mergePackageJson($
|
|
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'], $
|
|
182
|
+
unset($base['package.json'], $nextHashes['package.json'], $yours['package.json']);
|
|
169
183
|
|
|
170
|
-
$plan = DashboardUpdate::plan($base, $
|
|
184
|
+
$plan = DashboardUpdate::plan($base, $nextHashes, $yours);
|
|
171
185
|
$this->components->info(($check ? 'Dashboard: what would change, ' : 'Dashboard: ')."template {$from} → {$to}");
|
|
172
186
|
|
|
173
|
-
$
|
|
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
|
-
$
|
|
176
|
-
if (! $check
|
|
177
|
-
$
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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 ($
|
|
212
|
-
$
|
|
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 ($
|
|
215
|
-
$this->line(
|
|
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).' &&
|
|
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
|
-
|
|
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
|
-
$
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
$web = $this->webPath();
|
|
275
|
+
if ($web === null) {
|
|
276
|
+
$this->components->error('No dashboard at nevela.web_path.');
|
|
237
277
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
287
|
+
}
|
|
288
|
+
if ($last === null) {
|
|
289
|
+
$this->components->info('There is no update to undo: no backup was found.');
|
|
252
290
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
$
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
$
|
|
287
|
-
$
|
|
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.
|
|
20
|
+
public const VERSION = '0.1.5';
|
|
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
|
|