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.
- package/README.md +12 -3
- package/index.mjs +194 -115
- 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/DevCommand.php +104 -0
- package/template/laravel/src/Console/MakeResourceCommand.php +11 -3
- package/template/laravel/src/Console/SetupCommand.php +55 -0
- package/template/laravel/src/Console/StatusCommand.php +195 -0
- package/template/laravel/src/Console/UpdateCommand.php +180 -113
- package/template/laravel/src/Console/VersionCommand.php +20 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -8
- package/template/laravel/src/Nevela.php +161 -151
- package/template/laravel/src/NevelaServiceProvider.php +15 -1
- package/template/laravel/src/Support/DashboardState.php +118 -0
- package/template/laravel/src/Support/DashboardUpdate.php +57 -20
- package/template/laravel/src/Support/Releases.php +45 -0
- package/template/laravel/src/Support/TemplateSource.php +144 -0
- package/template/web/app/auth-actions.ts +12 -4
- package/template/web/package.json +1 -1
- package/template.mjs +127 -94
package/project.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `nevela <command>` inside an existing app.
|
|
2
|
+
//
|
|
3
|
+
// The same commands as the app's own `php nevela`, but started from Node. That matters in
|
|
4
|
+
// shells where typing `php` doesn't work (Git Bash with Laravel Herd, where PHP is
|
|
5
|
+
// php.bat), and for `update`, which has to work on an app of any age, including one from
|
|
6
|
+
// before the app had a launcher at all.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawnCommand } from './shell.mjs';
|
|
11
|
+
|
|
12
|
+
/** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
|
|
13
|
+
const NEVELA = ['update', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
|
|
14
|
+
/** These go to the artisan command of the same name. */
|
|
15
|
+
const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
|
|
16
|
+
|
|
17
|
+
export const COMMANDS = [...NEVELA, ...ARTISAN, 'artisan'];
|
|
18
|
+
|
|
19
|
+
/** The app this folder belongs to: the nearest parent holding apps/api/artisan. */
|
|
20
|
+
export function findProject(from) {
|
|
21
|
+
let dir = path.resolve(from);
|
|
22
|
+
for (;;) {
|
|
23
|
+
if (fs.existsSync(path.join(dir, 'apps', 'api', 'artisan'))) {
|
|
24
|
+
return { root: dir, api: path.join(dir, 'apps', 'api'), web: path.join(dir, 'apps', 'web') };
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir) return null;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The artisan arguments for `nevela <name> …args`. */
|
|
33
|
+
export function artisanArguments(name, args) {
|
|
34
|
+
if (name === 'artisan') return args;
|
|
35
|
+
if (ARTISAN.includes(name)) return [name, ...args];
|
|
36
|
+
// A new resource is no use until its table exists, so make it in the same step.
|
|
37
|
+
const rest = args.filter((arg) => arg !== '--no-migrate');
|
|
38
|
+
if (name === 'resource' && !args.includes('--no-migrate')) rest.push('--migrate');
|
|
39
|
+
return [`nevela:${name}`, ...rest];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What stops Composer from updating nevela/laravel, repaired in composer.json.
|
|
44
|
+
* Returns a line describing each repair, for the user to see.
|
|
45
|
+
*/
|
|
46
|
+
export function repairManifest(manifest, latest) {
|
|
47
|
+
const repairs = [];
|
|
48
|
+
const current = manifest.require?.['nevela/laravel'];
|
|
49
|
+
const caret = latest ? `^${latest.split('.').slice(0, 2).join('.')}` : null;
|
|
50
|
+
|
|
51
|
+
// Installed from a folder in the app, from before the package was on Packagist.
|
|
52
|
+
const repositories = Array.isArray(manifest.repositories) ? manifest.repositories : Object.values(manifest.repositories ?? {});
|
|
53
|
+
const bundled = repositories.filter((repo) => repo?.type === 'path' && /nevela-laravel\/?$/.test(String(repo.url).replace(/\\/g, '/')));
|
|
54
|
+
if (bundled.length && caret) {
|
|
55
|
+
const kept = repositories.filter((repo) => !bundled.includes(repo));
|
|
56
|
+
if (kept.length) manifest.repositories = kept;
|
|
57
|
+
else delete manifest.repositories;
|
|
58
|
+
manifest.require['nevela/laravel'] = caret;
|
|
59
|
+
repairs.push(`nevela/laravel now comes from Packagist (${caret}) instead of the packages/nevela-laravel folder, which you can delete`);
|
|
60
|
+
return repairs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Windows installs from create-nevela 0.1.0 dropped the caret: "0.1" pins one exact version.
|
|
64
|
+
if (typeof current === 'string' && /^\d+\.\d+(\.\d+)?$/.test(current)) {
|
|
65
|
+
manifest.require['nevela/laravel'] = caret ?? `^${current.split('.').slice(0, 2).join('.')}`;
|
|
66
|
+
repairs.push(`"nevela/laravel": "${current}" pinned one exact version; it is now "${manifest.require['nevela/laravel']}"`);
|
|
67
|
+
}
|
|
68
|
+
return repairs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Make sure `pnpm nevela …` and a port-safe `dev` exist in the app's root package.json. */
|
|
72
|
+
export function repairScripts(root) {
|
|
73
|
+
const file = path.join(root, 'package.json');
|
|
74
|
+
if (!fs.existsSync(file)) return [];
|
|
75
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
+
const repairs = [];
|
|
77
|
+
manifest.scripts ??= {};
|
|
78
|
+
if (!manifest.scripts.nevela) {
|
|
79
|
+
manifest.scripts.nevela = 'php nevela';
|
|
80
|
+
repairs.push('added the "nevela" script: pnpm nevela <command>');
|
|
81
|
+
}
|
|
82
|
+
if (manifest.scripts.dev === 'node scripts/dev.mjs') {
|
|
83
|
+
manifest.scripts.dev = 'php nevela dev';
|
|
84
|
+
repairs.push('"dev" now runs php nevela dev, which picks a free port');
|
|
85
|
+
}
|
|
86
|
+
if (repairs.length) fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
87
|
+
return repairs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run a command in the app's Laravel folder, attached to this terminal. Returns its exit code. */
|
|
91
|
+
function inApp(project, command, args) {
|
|
92
|
+
const result = spawnCommand(command, args, { cwd: project.api, stdio: 'inherit' });
|
|
93
|
+
if (result.error) {
|
|
94
|
+
console.error(`\n Couldn't run ${command}: ${result.error.message}\n`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
return result.status ?? 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `nevela update`: bring an app of any age to the latest Nevela. */
|
|
101
|
+
export async function update(project, args, { latest, say }) {
|
|
102
|
+
const check = args.includes('--check');
|
|
103
|
+
const manifestFile = path.join(project.api, 'composer.json');
|
|
104
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
105
|
+
const before = JSON.stringify(manifest);
|
|
106
|
+
const repairs = repairManifest(manifest, latest);
|
|
107
|
+
|
|
108
|
+
if (!check) {
|
|
109
|
+
if (JSON.stringify(manifest) !== before) fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 4)}\n`);
|
|
110
|
+
repairs.push(...repairScripts(project.root));
|
|
111
|
+
for (const repair of repairs) say(repair);
|
|
112
|
+
|
|
113
|
+
// Composer first, from here: an app from before 0.1.2 has no update command of its own yet.
|
|
114
|
+
const status = inApp(project, 'composer', ['update', 'nevela/laravel', '--no-interaction', '--no-progress']);
|
|
115
|
+
if (status !== 0) return status;
|
|
116
|
+
return inApp(project, 'php', ['artisan', 'nevela:update', '--skip-package', ...args]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const repair of repairs) say(`would fix: ${repair}`);
|
|
120
|
+
return inApp(project, 'php', ['artisan', 'nevela:update', ...args]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Any other command: hand it to artisan in the app. */
|
|
124
|
+
export function forward(project, name, args) {
|
|
125
|
+
return inApp(project, 'php', ['artisan', ...artisanArguments(name, args)]);
|
|
126
|
+
}
|
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,104 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Symfony\Component\Process\PhpExecutableFinder;
|
|
7
|
+
use Symfony\Component\Process\Process;
|
|
8
|
+
|
|
9
|
+
final class DevCommand extends Command
|
|
10
|
+
{
|
|
11
|
+
protected $signature = 'nevela:dev
|
|
12
|
+
{--port=8000 : The first port to try for the API}
|
|
13
|
+
{--api-only : Start the API without the dashboard}';
|
|
14
|
+
|
|
15
|
+
protected $description = 'Run the API and the dashboard together';
|
|
16
|
+
|
|
17
|
+
public function handle(): int
|
|
18
|
+
{
|
|
19
|
+
$wanted = max(1, (int) $this->option('port'));
|
|
20
|
+
$port = self::freePort($wanted);
|
|
21
|
+
if ($port === null) {
|
|
22
|
+
$this->components->error("Ports {$wanted} to ".($wanted + 19).' are all in use. Free one, or pass --port.');
|
|
23
|
+
|
|
24
|
+
return self::FAILURE;
|
|
25
|
+
}
|
|
26
|
+
if ($port !== $wanted) {
|
|
27
|
+
// Without this check the dashboard would talk to whatever already owns the port.
|
|
28
|
+
// On Windows two servers can listen on the same one, and the older one answers.
|
|
29
|
+
$this->components->warn("Port {$wanted} is already in use by another program, so the API is on {$port}.");
|
|
30
|
+
}
|
|
31
|
+
$url = "http://127.0.0.1:{$port}";
|
|
32
|
+
|
|
33
|
+
$php = (new PhpExecutableFinder)->find(false) ?: 'php';
|
|
34
|
+
$api = new Process([$php, 'artisan', 'serve', '--host=127.0.0.1', "--port={$port}"], base_path(), null, null, null);
|
|
35
|
+
|
|
36
|
+
$web = null;
|
|
37
|
+
$webPath = config('nevela.web_path');
|
|
38
|
+
if (! $this->option('api-only') && $webPath && is_dir($webPath)) {
|
|
39
|
+
// The dashboard is told where the API really is, whatever its .env.local says.
|
|
40
|
+
$web = Process::fromShellCommandline(self::packageManager($webPath).' run dev', $webPath, ['NEVELA_API_URL' => "{$url}/api"], null, null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$this->components->info('Starting '.($web ? 'the API and the dashboard' : 'the API').'. Press Ctrl+C to stop.');
|
|
44
|
+
$this->components->twoColumnDetail('API', $url);
|
|
45
|
+
if ($web) {
|
|
46
|
+
$this->components->twoColumnDetail('Dashboard', 'http://localhost:3000/sign-in (see below if that port is taken)');
|
|
47
|
+
}
|
|
48
|
+
$this->newLine();
|
|
49
|
+
|
|
50
|
+
$api->start($this->printer('api', 35));
|
|
51
|
+
$web?->start($this->printer('web', 36));
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
while ($api->isRunning() && ($web === null || $web->isRunning())) {
|
|
55
|
+
usleep(200_000);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
// Whichever stopped, or Ctrl+C: don't leave the other one running behind.
|
|
59
|
+
$web?->stop(3);
|
|
60
|
+
$api->stop(3);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return self::SUCCESS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The first port from `$from` that nothing is listening on, or null if twenty in a row are taken. */
|
|
67
|
+
public static function freePort(int $from): ?int
|
|
68
|
+
{
|
|
69
|
+
for ($port = $from; $port < $from + 20; $port++) {
|
|
70
|
+
$socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.3);
|
|
71
|
+
if ($socket === false) {
|
|
72
|
+
return $port;
|
|
73
|
+
}
|
|
74
|
+
fclose($socket);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The package manager a folder was installed with, from its lock file. */
|
|
81
|
+
public static function packageManager(string $dir): string
|
|
82
|
+
{
|
|
83
|
+
return match (true) {
|
|
84
|
+
is_file("{$dir}/pnpm-lock.yaml"), is_file("{$dir}/pnpm-workspace.yaml") => 'pnpm',
|
|
85
|
+
is_file("{$dir}/yarn.lock") => 'yarn',
|
|
86
|
+
is_file("{$dir}/bun.lockb"), is_file("{$dir}/bun.lock") => 'bun',
|
|
87
|
+
default => 'npm',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Prints a process's output a line at a time, each line labelled with which app it came from. */
|
|
92
|
+
private function printer(string $name, int $colour): \Closure
|
|
93
|
+
{
|
|
94
|
+
$rest = '';
|
|
95
|
+
|
|
96
|
+
return function (string $type, string $chunk) use ($name, $colour, &$rest) {
|
|
97
|
+
$lines = explode("\n", $rest.str_replace("\r\n", "\n", $chunk));
|
|
98
|
+
$rest = array_pop($lines);
|
|
99
|
+
foreach ($lines as $line) {
|
|
100
|
+
$this->output->writeln("\e[{$colour}m{$name}\e[0m │ ".$line, \Symfony\Component\Console\Output\OutputInterface::OUTPUT_RAW);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -16,7 +16,8 @@ final class MakeResourceCommand extends Command
|
|
|
16
16
|
{--icon= : Lucide icon name for the dashboard, e.g. package}
|
|
17
17
|
{--group= : Sidebar group in the dashboard}
|
|
18
18
|
{--force : Overwrite an existing descriptor}
|
|
19
|
-
{--migrate : Run the new migration straight away}
|
|
19
|
+
{--migrate : Run the new migration straight away}
|
|
20
|
+
{--seed= : Fill the new resource with records too (25, or the number you give). Runs the migration first}';
|
|
20
21
|
|
|
21
22
|
protected $description = 'Describe a resource once, then generate its Laravel API and Flare dashboard descriptor';
|
|
22
23
|
|
|
@@ -51,12 +52,19 @@ final class MakeResourceCommand extends Command
|
|
|
51
52
|
Nevela::forget();
|
|
52
53
|
$status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
// "--seed" on its own and "--seed=100" both count; without a number it is 25.
|
|
56
|
+
$seed = $this->input->hasParameterOption('--seed') || $this->option('seed') !== null;
|
|
57
|
+
$migrate = $this->option('migrate') || $seed;
|
|
58
|
+
|
|
59
|
+
if ($status === self::SUCCESS && $migrate) {
|
|
55
60
|
$status = $this->call('migrate');
|
|
56
61
|
}
|
|
62
|
+
if ($status === self::SUCCESS && $seed) {
|
|
63
|
+
$status = $this->call('nevela:seed', ['name' => $descriptor->name, '--count' => $this->option('seed') ?: 25]);
|
|
64
|
+
}
|
|
57
65
|
|
|
58
66
|
$this->newLine();
|
|
59
|
-
$this->line($
|
|
67
|
+
$this->line($migrate
|
|
60
68
|
? ' Next: tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.'
|
|
61
69
|
: ' Next: <fg=cyan>php artisan migrate</>, then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
|
|
62
70
|
$this->line(" API: GET|POST /{$this->prefix()}{$descriptor->slug} · GET|PUT|PATCH|DELETE /{$this->prefix()}{$descriptor->slug}/{id} · GET /{$this->prefix()}{$descriptor->slug}/_stats");
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Support\Facades\DB;
|
|
7
|
+
use Illuminate\Support\Facades\Http;
|
|
8
|
+
use Nevela\Laravel\Nevela;
|
|
9
|
+
use Nevela\Laravel\Support\DashboardState;
|
|
10
|
+
use Nevela\Laravel\Support\Releases;
|
|
11
|
+
use Throwable;
|
|
12
|
+
|
|
13
|
+
final class StatusCommand extends Command
|
|
14
|
+
{
|
|
15
|
+
protected $signature = 'nevela:status';
|
|
16
|
+
|
|
17
|
+
protected $description = 'Check the app: versions, the database, migrations, users, resources and the dashboard';
|
|
18
|
+
|
|
19
|
+
public function handle(): int
|
|
20
|
+
{
|
|
21
|
+
$problems = 0;
|
|
22
|
+
$row = fn (string $label, string $value) => $this->components->twoColumnDetail($label, $value);
|
|
23
|
+
$bad = function (string $text) use (&$problems): string {
|
|
24
|
+
$problems++;
|
|
25
|
+
|
|
26
|
+
return "<fg=red>{$text}</>";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
$this->newLine();
|
|
30
|
+
$latest = Releases::latest();
|
|
31
|
+
$row('Nevela', Nevela::VERSION.match (true) {
|
|
32
|
+
$latest === null => ' <fg=gray>(couldn\'t check for a newer one)</>',
|
|
33
|
+
version_compare($latest, Nevela::VERSION, '>') => " <fg=yellow>({$latest} is out: php nevela update)</>",
|
|
34
|
+
default => ' <fg=green>(latest)</>',
|
|
35
|
+
});
|
|
36
|
+
$row('Laravel / PHP', app()->version().' / '.PHP_VERSION);
|
|
37
|
+
|
|
38
|
+
// --- database ---
|
|
39
|
+
try {
|
|
40
|
+
$connection = DB::connection();
|
|
41
|
+
$connection->getPdo();
|
|
42
|
+
$name = $connection->getDatabaseName();
|
|
43
|
+
$row('Database', $connection->getDriverName().($name ? ' · '.str_replace(base_path().DIRECTORY_SEPARATOR, '', (string) $name) : ''));
|
|
44
|
+
|
|
45
|
+
$migrator = app('migrator');
|
|
46
|
+
if (! $migrator->repositoryExists()) {
|
|
47
|
+
$row('Migrations', $bad('none have run: php nevela migrate'));
|
|
48
|
+
} else {
|
|
49
|
+
$ran = $migrator->getRepository()->getRan();
|
|
50
|
+
$files = array_keys($migrator->getMigrationFiles([...$migrator->paths(), database_path('migrations')]));
|
|
51
|
+
$pending = array_values(array_diff($files, $ran));
|
|
52
|
+
$row('Migrations', count($ran).' ran, '.($pending === []
|
|
53
|
+
? '<fg=green>none pending</>'
|
|
54
|
+
: $bad(count($pending).' pending: php nevela migrate')));
|
|
55
|
+
foreach ($pending as $migration) {
|
|
56
|
+
$this->line(" <fg=gray>pending</> {$migration}");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$model = config('auth.providers.users.model');
|
|
61
|
+
if (is_string($model) && class_exists($model)) {
|
|
62
|
+
try {
|
|
63
|
+
$users = $model::query()->count();
|
|
64
|
+
$row('Users who can sign in', $users > 0 ? (string) $users : $bad('none: php nevela user'));
|
|
65
|
+
} catch (Throwable) {
|
|
66
|
+
$row('Users who can sign in', $bad('the users table is missing: php nevela migrate'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (Throwable $e) {
|
|
70
|
+
$row('Database', $bad('can\'t connect: '.str($e->getMessage())->limit(90)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- resources ---
|
|
74
|
+
try {
|
|
75
|
+
$resources = Nevela::all();
|
|
76
|
+
} catch (Throwable $e) {
|
|
77
|
+
$resources = [];
|
|
78
|
+
$row('Resources', $bad('a descriptor can\'t be read: '.str($e->getMessage())->limit(90)));
|
|
79
|
+
}
|
|
80
|
+
if ($resources === []) {
|
|
81
|
+
$row('Resources', '<fg=gray>none yet: php nevela resource Product --fields="name:string"</>');
|
|
82
|
+
}
|
|
83
|
+
foreach ($resources as $resource) {
|
|
84
|
+
try {
|
|
85
|
+
$row("Resource: {$resource->name}", number_format(DB::table($resource->table)->count()).' records');
|
|
86
|
+
} catch (Throwable) {
|
|
87
|
+
$row("Resource: {$resource->name}", $bad("the {$resource->table} table is missing: php nevela migrate"));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- dashboard ---
|
|
92
|
+
$web = config('nevela.web_path');
|
|
93
|
+
if (! $web || ! is_dir($web)) {
|
|
94
|
+
$row('Dashboard', '<fg=gray>not found at nevela.web_path</>');
|
|
95
|
+
} else {
|
|
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
|
+
}
|
|
120
|
+
|
|
121
|
+
// `php nevela dev` may have put the API on another port and told the dashboard so,
|
|
122
|
+
// which is fine. Look for this app wherever it is running before judging.
|
|
123
|
+
$url = self::apiUrl($web);
|
|
124
|
+
$who = self::whoAnswers($url);
|
|
125
|
+
$running = $who === 'this' ? $url : self::findRunning();
|
|
126
|
+
|
|
127
|
+
if ($who === 'this') {
|
|
128
|
+
$row('API', "{$url} <fg=green>(running, and it is this app)</>");
|
|
129
|
+
} elseif ($running !== null) {
|
|
130
|
+
$row('API', "{$running} <fg=green>(running, and it is this app)</>");
|
|
131
|
+
$row('', '<fg=gray>'.parse_url($url, PHP_URL_PORT).' belongs to another program; php nevela dev chose a free port</>');
|
|
132
|
+
} elseif ($who === 'nobody') {
|
|
133
|
+
$row('API', '<fg=gray>not running: php nevela dev</>');
|
|
134
|
+
} else {
|
|
135
|
+
$row('API', $bad("{$url} is answered by ".($who === 'nevela' ? 'another Nevela app' : 'another program').', not this app'));
|
|
136
|
+
$row('', '<fg=gray>start with php nevela dev, which picks a free port and tells the dashboard</>');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
$this->newLine();
|
|
140
|
+
|
|
141
|
+
if ($problems > 0) {
|
|
142
|
+
$this->components->warn($problems === 1 ? 'One thing needs attention, marked in red above.' : "{$problems} things need attention, marked in red above.");
|
|
143
|
+
|
|
144
|
+
return self::FAILURE;
|
|
145
|
+
}
|
|
146
|
+
$this->components->info('Everything looks right.');
|
|
147
|
+
|
|
148
|
+
return self::SUCCESS;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Where the dashboard is told the API is: NEVELA_API_URL in its .env.local, else the default. */
|
|
152
|
+
public static function apiUrl(string $web): string
|
|
153
|
+
{
|
|
154
|
+
$env = (string) @file_get_contents(rtrim($web, '/\\').'/.env.local');
|
|
155
|
+
if (preg_match('/^\s*NEVELA_API_URL\s*=\s*["\']?([^"\'\r\n#]+)/m', $env, $m)) {
|
|
156
|
+
return rtrim(trim($m[1]), '/');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 'http://127.0.0.1:8000/api';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Who answers at an API address: 'this' app, another 'nevela' app, some 'other' program, or 'nobody'. */
|
|
163
|
+
public static function whoAnswers(string $url): string
|
|
164
|
+
{
|
|
165
|
+
try {
|
|
166
|
+
$response = Http::timeout(3)->acceptJson()->get($url.'/_nevela/ping');
|
|
167
|
+
} catch (Throwable) {
|
|
168
|
+
return 'nobody';
|
|
169
|
+
}
|
|
170
|
+
if ($response->json('app') === Nevela::fingerprint()) {
|
|
171
|
+
return 'this';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return $response->json('nevela') !== null ? 'nevela' : 'other';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** This app's API on one of the ports `php nevela dev` would use, or null if it isn't running. */
|
|
178
|
+
private static function findRunning(): ?string
|
|
179
|
+
{
|
|
180
|
+
$prefix = trim((string) config('nevela.prefix', 'api'), '/');
|
|
181
|
+
for ($port = 8000; $port < 8020; $port++) {
|
|
182
|
+
$socket = @fsockopen('127.0.0.1', $port, $code, $message, 0.15);
|
|
183
|
+
if ($socket === false) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
fclose($socket);
|
|
187
|
+
$url = "http://127.0.0.1:{$port}".($prefix === '' ? '' : "/{$prefix}");
|
|
188
|
+
if (self::whoAnswers($url) === 'this') {
|
|
189
|
+
return $url;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|