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.
@@ -0,0 +1,45 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Support\Facades\Http;
6
+ use Throwable;
7
+
8
+ /** What Packagist knows about nevela/laravel. */
9
+ final class Releases
10
+ {
11
+ private static string|null|false $latest = false;
12
+
13
+ /** The newest stable release, or null when Packagist can't be reached. Asked once per run. */
14
+ public static function latest(): ?string
15
+ {
16
+ if (self::$latest !== false) {
17
+ return self::$latest;
18
+ }
19
+ try {
20
+ $releases = Http::timeout(8)->get('https://repo.packagist.org/p2/nevela/laravel.json')->json('packages.nevela/laravel') ?? [];
21
+ } catch (Throwable) {
22
+ return self::$latest = null;
23
+ }
24
+
25
+ return self::$latest = self::newest(array_map(fn ($release) => (string) ($release['version'] ?? ''), $releases));
26
+ }
27
+
28
+ /**
29
+ * The highest x.y.z in a list of version names. Branches and pre-releases don't count.
30
+ *
31
+ * @param list<string> $names
32
+ */
33
+ public static function newest(array $names): ?string
34
+ {
35
+ $versions = [];
36
+ foreach ($names as $name) {
37
+ if (preg_match('/^v?(\d+\.\d+\.\d+)$/', $name, $m)) {
38
+ $versions[] = $m[1];
39
+ }
40
+ }
41
+ usort($versions, 'version_compare');
42
+
43
+ return $versions === [] ? null : end($versions);
44
+ }
45
+ }
@@ -0,0 +1,144 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Support\Facades\Http;
6
+ use PharData;
7
+ use RecursiveDirectoryIterator;
8
+ use RecursiveIteratorIterator;
9
+ use RuntimeException;
10
+ use Throwable;
11
+
12
+ /**
13
+ * The dashboard as it shipped in a given version of Nevela.
14
+ *
15
+ * Tried in order, so an update never depends on one place being up to date:
16
+ *
17
+ * 1. NEVELA_TEMPLATE_DIR, a folder of create-nevela-<version>.tgz files. For working on
18
+ * Nevela itself, and for updating without a connection.
19
+ * 2. The create-nevela package on npm, which carries the dashboard.
20
+ * 3. The repository's own tag on GitHub. Tags are pushed before anything is published,
21
+ * so this works in the gap between a release and its npm package.
22
+ */
23
+ final class TemplateSource
24
+ {
25
+ private const NPM = 'https://registry.npmjs.org/create-nevela/-/create-nevela-%s.tgz';
26
+
27
+ private const GITHUB = 'https://codeload.github.com/MarkColeMukisa/nevela/tar.gz/refs/tags/v%s';
28
+
29
+ /** Never part of an app's dashboard: build output, installs and local settings. */
30
+ private const NEVER = ['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts'];
31
+
32
+ /**
33
+ * @return array{files: array<string, string>, from: string} path => contents, and where it came from
34
+ */
35
+ public static function fetch(string $version): array
36
+ {
37
+ $dir = rtrim(sys_get_temp_dir(), '/\\').DIRECTORY_SEPARATOR.'nevela-template-'.$version.'-'.bin2hex(random_bytes(4));
38
+ mkdir($dir, 0775, true);
39
+ $archive = $dir.DIRECTORY_SEPARATOR.'template.tgz';
40
+ $tried = [];
41
+
42
+ try {
43
+ $local = rtrim((string) env('NEVELA_TEMPLATE_DIR', ''), '/\\');
44
+ if ($local !== '' && is_file("{$local}/create-nevela-{$version}.tgz")) {
45
+ copy("{$local}/create-nevela-{$version}.tgz", $archive);
46
+
47
+ return ['files' => self::read($archive, $dir, 'package/template/web', false), 'from' => 'NEVELA_TEMPLATE_DIR'];
48
+ }
49
+
50
+ foreach (['npm' => [self::NPM, 'package/template/web', false], 'GitHub' => [self::GITHUB, "nevela-{$version}/apps/web", true]] as $name => [$url, $root, $filter]) {
51
+ try {
52
+ $response = Http::timeout(60)->get(sprintf($url, $version));
53
+ if (! $response->successful()) {
54
+ $tried[] = "{$name}: HTTP {$response->status()}";
55
+
56
+ continue;
57
+ }
58
+ file_put_contents($archive, $response->body());
59
+
60
+ return ['files' => self::read($archive, $dir.DIRECTORY_SEPARATOR.strtolower($name), $root, $filter), 'from' => $name];
61
+ } catch (Throwable $e) {
62
+ $tried[] = "{$name}: ".$e->getMessage();
63
+ }
64
+ }
65
+
66
+ throw new RuntimeException("version {$version} was not found (".implode('; ', $tried).')');
67
+ } finally {
68
+ self::remove($dir);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Unpack an archive and read the dashboard out of it.
74
+ *
75
+ * @param bool $filter Whether this is the repository's own apps/web, whose example resources aren't part of the template
76
+ * @return array<string, string>
77
+ */
78
+ private static function read(string $archive, string $into, string $root, bool $filter): array
79
+ {
80
+ if (! is_dir($into)) {
81
+ mkdir($into, 0775, true);
82
+ }
83
+ (new PharData($archive))->extractTo($into, null, true);
84
+ $base = $into.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $root);
85
+ if (! is_dir($base)) {
86
+ throw new RuntimeException('the archive has no dashboard in it');
87
+ }
88
+
89
+ $files = [];
90
+ foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
91
+ $path = str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1));
92
+ if (array_intersect(explode('/', $path), self::NEVER) !== []) {
93
+ continue;
94
+ }
95
+ // npm can't carry .gitignore files, so its template holds them as _gitignore.
96
+ $path = preg_replace('#(^|/)_gitignore$#', '$1.gitignore', $path);
97
+ $files[$path] = (string) file_get_contents($file->getPathname());
98
+ }
99
+
100
+ return $filter ? self::withoutExamples($files) : $files;
101
+ }
102
+
103
+ /**
104
+ * The repository's dashboard has example resources in it (a Product, say). They are
105
+ * generated from Laravel, not part of what a new app is given.
106
+ *
107
+ * @param array<string, string> $files
108
+ * @return array<string, string>
109
+ */
110
+ public static function withoutExamples(array $files): array
111
+ {
112
+ $slugs = [];
113
+ foreach ($files as $path => $contents) {
114
+ if (preg_match('#^resources/[^/]+\.resource\.ts$#', $path) && preg_match('/slug:\s*"([^"]+)"/', $contents, $m)) {
115
+ $slugs[] = $m[1];
116
+ }
117
+ }
118
+
119
+ return array_filter($files, function (string $path) use ($slugs) {
120
+ if (preg_match('#^resources/[^/]+\.resource\.ts$#', $path) || $path === 'resources/index.ts') {
121
+ return false;
122
+ }
123
+ foreach ($slugs as $slug) {
124
+ if (str_starts_with($path, "app/dashboard/{$slug}/")) {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ return true;
130
+ }, ARRAY_FILTER_USE_KEY);
131
+ }
132
+
133
+ private static function remove(string $dir): void
134
+ {
135
+ if (! is_dir($dir)) {
136
+ return;
137
+ }
138
+ $items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
139
+ foreach ($items as $item) {
140
+ $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
141
+ }
142
+ @rmdir($dir);
143
+ }
144
+ }
@@ -1,7 +1,7 @@
1
1
  "use server";
2
2
 
3
3
  import { cookies } from "next/headers";
4
- import { laravel, TOKEN_COOKIE } from "@/lib/laravel";
4
+ import { API_URL, laravel, TOKEN_COOKIE } from "@/lib/laravel";
5
5
 
6
6
  export type SignInResult = { ok: true } | { ok: false; error: string };
7
7
 
@@ -17,12 +17,20 @@ export async function signInAction(email: string, password: string): Promise<Sig
17
17
  try {
18
18
  response = await laravel("auth/token", { method: "POST", body: { email, password }, token: null });
19
19
  } catch {
20
- return { ok: false, error: "Can't reach the server. Check that the Laravel app is running." };
20
+ return { ok: false, error: `Can't reach the server at ${API_URL}. Start it with: php nevela dev` };
21
21
  }
22
22
  if (!response.ok) {
23
- const body = (response.body ?? {}) as { error?: string; issues?: { message: string }[] };
23
+ const body = (response.body ?? {}) as { error?: string; message?: string; issues?: { message: string }[] };
24
24
  if (response.status === 429) return { ok: false, error: "Too many attempts. Wait a minute and try again." };
25
- return { ok: false, error: body.issues?.[0]?.message ?? body.error ?? "Sign-in failed. Try again." };
25
+ if (response.status === 404 || response.status === 405) {
26
+ // Something answered, but it has no sign-in route: it isn't this app's API. Usually
27
+ // another program already had the port when the API was started.
28
+ return {
29
+ ok: false,
30
+ error: `${API_URL} is answered by a different program, not this app's API. Stop what is using that port, or start both with "php nevela dev", which picks a free one.`,
31
+ };
32
+ }
33
+ return { ok: false, error: body.issues?.[0]?.message ?? body.error ?? body.message ?? "Sign-in failed. Try again." };
26
34
  }
27
35
 
28
36
  const { token } = response.body as { token: string };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/template.mjs CHANGED
@@ -1,94 +1,127 @@
1
- // Where the new app's files come from.
2
- //
3
- // Published to npm, this package carries a `template/` folder built by build-template.mjs.
4
- // Run from the repository, it reads apps/web and packages/laravel directly, so there is
5
- // one source for the dashboard and nothing to keep in sync by hand.
6
-
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- const here = path.dirname(fileURLToPath(import.meta.url));
12
- const bundled = path.join(here, 'template');
13
- const fromRepo = !fs.existsSync(bundled);
14
-
15
- export const sources = {
16
- web: fromRepo ? path.resolve(here, '..', '..', 'apps', 'web') : path.join(bundled, 'web'),
17
- laravel: fromRepo ? path.resolve(here, '..', 'laravel') : path.join(bundled, 'laravel'),
18
- };
19
-
20
- /** Never part of a new app: build output, installs and local settings. */
21
- const NEVER = new Set(['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'vendor', '.phpunit.result.cache', '.phpunit.cache', 'composer.lock']);
22
-
23
- function copyTree(from, to, skip = () => false, relative = '') {
24
- fs.mkdirSync(to, { recursive: true });
25
- for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
26
- const inside = relative ? `${relative}/${entry.name}` : entry.name;
27
- if (NEVER.has(entry.name) || skip(inside, entry)) continue;
28
- const source = path.join(from, entry.name);
29
- // npm leaves .gitignore files out of a published package, so the bundled template
30
- // carries them as _gitignore (see buildTemplate) and they get their name back here.
31
- const target = path.join(to, entry.name === '_gitignore' ? '.gitignore' : entry.name);
32
- if (entry.isDirectory()) copyTree(source, target, skip, inside);
33
- else if (entry.isFile()) fs.copyFileSync(source, target);
34
- }
35
- }
36
-
37
- /**
38
- * What belongs to the framework repository's own example resources, not to a new app:
39
- * their descriptors, the registry that lists them, and their dashboard pages.
40
- */
41
- export function exampleResourceFilter(webRoot) {
42
- const resources = path.join(webRoot, 'resources');
43
- const slugs = new Set();
44
- if (fs.existsSync(resources)) {
45
- for (const file of fs.readdirSync(resources)) {
46
- if (!file.endsWith('.resource.ts')) continue;
47
- const slug = /slug:\s*"([^"]+)"/.exec(fs.readFileSync(path.join(resources, file), 'utf8'));
48
- if (slug) slugs.add(slug[1]);
49
- }
50
- }
51
- return (inside) =>
52
- /^resources\/[^/]+\.resource\.ts$/.test(inside)
53
- || inside === 'resources/index.ts'
54
- || [...slugs].some((slug) => inside === `app/dashboard/${slug}`);
55
- }
56
-
57
- /** The dashboard, without the repository's example resources, named for the new app. */
58
- export function copyWebTemplate(to, { name, title }) {
59
- copyTree(sources.web, to, exampleResourceFilter(sources.web));
60
-
61
- const packageFile = path.join(to, 'package.json');
62
- const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
63
- manifest.name = `${name}-web`;
64
- fs.writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`);
65
-
66
- const siteFile = path.join(to, 'lib', 'site.ts');
67
- const site = fs.readFileSync(siteFile, 'utf8')
68
- .replace('name: "Nevela"', `name: ${JSON.stringify(title)}`)
69
- .replace('description: "Nevela keeps', `description: "${title.replace(/"/g, '')} keeps`);
70
- fs.writeFileSync(siteFile, site);
71
-
72
- // The generator fills this in; it has to exist for the first build.
73
- fs.mkdirSync(path.join(to, 'resources'), { recursive: true });
74
- }
75
-
76
- /** The Laravel package, for installing by path until it is on Packagist. */
77
- export function copyLaravelPackage(to) {
78
- copyTree(sources.laravel, to, (inside) => inside === 'tests' || inside === 'phpunit.xml');
79
- }
80
-
81
- /** Used by build-template.mjs: the same filtered copies, into this package's template/. */
82
- export function buildTemplate(to) {
83
- copyTree(sources.web, path.join(to, 'web'), exampleResourceFilter(sources.web));
84
- copyLaravelPackage(path.join(to, 'laravel'));
85
- hideGitignores(to);
86
- }
87
-
88
- function hideGitignores(dir) {
89
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
90
- const full = path.join(dir, entry.name);
91
- if (entry.isDirectory()) hideGitignores(full);
92
- else if (entry.name === '.gitignore') fs.renameSync(full, path.join(dir, '_gitignore'));
93
- }
94
- }
1
+ // Where the new app's files come from.
2
+ //
3
+ // Published to npm, this package carries a `template/` folder built by build-template.mjs.
4
+ // Run from the repository, it reads apps/web and packages/laravel directly, so there is
5
+ // one source for the dashboard and nothing to keep in sync by hand.
6
+
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const bundled = path.join(here, 'template');
14
+ // In a checkout the dashboard two folders up is the source, even when a template/ folder
15
+ // is lying around from an earlier `npm pack`: that copy may be stale.
16
+ const fromRepo = fs.existsSync(path.resolve(here, '..', '..', 'apps', 'web', 'package.json')) || !fs.existsSync(bundled);
17
+
18
+ /** True when this is a checkout of the repository rather than the package from npm. */
19
+ export const fromRepository = fromRepo;
20
+
21
+ export const sources = {
22
+ web: fromRepo ? path.resolve(here, '..', '..', 'apps', 'web') : path.join(bundled, 'web'),
23
+ laravel: fromRepo ? path.resolve(here, '..', 'laravel') : path.join(bundled, 'laravel'),
24
+ };
25
+
26
+ /** Never part of a new app: build output, installs and local settings. */
27
+ const NEVER = new Set(['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'vendor', '.phpunit.result.cache', '.phpunit.cache', 'composer.lock']);
28
+
29
+ function copyTree(from, to, skip = () => false, relative = '') {
30
+ fs.mkdirSync(to, { recursive: true });
31
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
32
+ const inside = relative ? `${relative}/${entry.name}` : entry.name;
33
+ if (NEVER.has(entry.name) || skip(inside, entry)) continue;
34
+ const source = path.join(from, entry.name);
35
+ // npm leaves .gitignore files out of a published package, so the bundled template
36
+ // carries them as _gitignore (see buildTemplate) and they get their name back here.
37
+ const target = path.join(to, entry.name === '_gitignore' ? '.gitignore' : entry.name);
38
+ if (entry.isDirectory()) copyTree(source, target, skip, inside);
39
+ else if (entry.isFile()) fs.copyFileSync(source, target);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * What belongs to the framework repository's own example resources, not to a new app:
45
+ * their descriptors, the registry that lists them, and their dashboard pages.
46
+ */
47
+ export function exampleResourceFilter(webRoot) {
48
+ const resources = path.join(webRoot, 'resources');
49
+ const slugs = new Set();
50
+ if (fs.existsSync(resources)) {
51
+ for (const file of fs.readdirSync(resources)) {
52
+ if (!file.endsWith('.resource.ts')) continue;
53
+ const slug = /slug:\s*"([^"]+)"/.exec(fs.readFileSync(path.join(resources, file), 'utf8'));
54
+ if (slug) slugs.add(slug[1]);
55
+ }
56
+ }
57
+ return (inside) =>
58
+ /^resources\/[^/]+\.resource\.ts$/.test(inside)
59
+ || inside === 'resources/index.ts'
60
+ || [...slugs].some((slug) => inside === `app/dashboard/${slug}`);
61
+ }
62
+
63
+ /** The dashboard, without the repository's example resources, named for the new app. */
64
+ export function copyWebTemplate(to, { name, title }) {
65
+ copyTree(sources.web, to, exampleResourceFilter(sources.web));
66
+
67
+ const packageFile = path.join(to, 'package.json');
68
+ const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
69
+ manifest.name = `${name}-web`;
70
+ fs.writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`);
71
+
72
+ const siteFile = path.join(to, 'lib', 'site.ts');
73
+ const site = fs.readFileSync(siteFile, 'utf8')
74
+ .replace('name: "Nevela"', `name: ${JSON.stringify(title)}`)
75
+ .replace('description: "Nevela keeps', `description: "${title.replace(/"/g, '')} keeps`);
76
+ fs.writeFileSync(siteFile, site);
77
+
78
+ // The generator fills this in; it has to exist for the first build.
79
+ fs.mkdirSync(path.join(to, 'resources'), { recursive: true });
80
+ }
81
+
82
+ /**
83
+ * A fingerprint of every template file, and the template's dependencies: what the app
84
+ * records in .nevela.json so that `nevela update` can tell, later and exactly, which
85
+ * files the developer changed. The same fingerprint the Laravel package computes: SHA-1
86
+ * with line endings ignored.
87
+ */
88
+ export function templateRecord(version) {
89
+ const skip = exampleResourceFilter(sources.web);
90
+ const files = {};
91
+ const walk = (dir, relative = '') => {
92
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
93
+ const inside = relative ? `${relative}/${entry.name}` : entry.name;
94
+ if (NEVER.has(entry.name) || skip(inside, entry)) continue;
95
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), inside);
96
+ else if (entry.isFile()) {
97
+ const bytes = fs.readFileSync(path.join(dir, entry.name)).toString('latin1').replace(/\r\n/g, '\n');
98
+ files[inside.replace(/(^|\/)_gitignore$/, '$1.gitignore')] = crypto.createHash('sha1').update(bytes, 'latin1').digest('hex');
99
+ }
100
+ }
101
+ };
102
+ walk(sources.web);
103
+ const manifest = JSON.parse(fs.readFileSync(path.join(sources.web, 'package.json'), 'utf8'));
104
+ const dependencies = {};
105
+ for (const section of ['dependencies', 'devDependencies']) if (manifest[section]) dependencies[section] = manifest[section];
106
+ return { template: version, files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))), dependencies };
107
+ }
108
+
109
+ /** The Laravel package, for installing by path until it is on Packagist. */
110
+ export function copyLaravelPackage(to) {
111
+ copyTree(sources.laravel, to, (inside) => inside === 'tests' || inside === 'phpunit.xml');
112
+ }
113
+
114
+ /** Used by build-template.mjs: the same filtered copies, into this package's template/. */
115
+ export function buildTemplate(to) {
116
+ copyTree(sources.web, path.join(to, 'web'), exampleResourceFilter(sources.web));
117
+ copyLaravelPackage(path.join(to, 'laravel'));
118
+ hideGitignores(to);
119
+ }
120
+
121
+ function hideGitignores(dir) {
122
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
123
+ const full = path.join(dir, entry.name);
124
+ if (entry.isDirectory()) hideGitignores(full);
125
+ else if (entry.name === '.gitignore') fs.renameSync(full, path.join(dir, '_gitignore'));
126
+ }
127
+ }