create-zerotal 1.0.0
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/package.json +43 -0
- package/src/index.ts +136 -0
- package/src/prompts.ts +81 -0
- package/src/scaffold.ts +104 -0
- package/templates/admin/_env.example +2 -0
- package/templates/admin/_gitignore +4 -0
- package/templates/admin/app/admin/ProductResource.ts +192 -0
- package/templates/admin/app/admin/SettingsResource.ts +37 -0
- package/templates/admin/app/admin/UserResource.ts +103 -0
- package/templates/admin/app/admin/index.ts +46 -0
- package/templates/admin/app/admin/shared.ts +36 -0
- package/templates/admin/app/admin/widgets.ts +51 -0
- package/templates/admin/app/models/Product.ts +21 -0
- package/templates/admin/app/models/Setting.ts +14 -0
- package/templates/admin/app/models/User.ts +20 -0
- package/templates/admin/bootstrap/app.ts +16 -0
- package/templates/admin/bootstrap/providers.ts +23 -0
- package/templates/admin/config/app.ts +22 -0
- package/templates/admin/config/auth.ts +5 -0
- package/templates/admin/config/cache.ts +7 -0
- package/templates/admin/config/database.ts +14 -0
- package/templates/admin/config/session.ts +10 -0
- package/templates/admin/database/seeders/DatabaseSeeder.ts +50 -0
- package/templates/admin/package.json +18 -0
- package/templates/admin/tests/admin_test.ts +58 -0
- package/templates/admin/tsconfig.json +20 -0
- package/templates/admin/zt.ts +15 -0
- package/templates/api/_env.example +4 -0
- package/templates/api/_gitignore +5 -0
- package/templates/api/app/controllers/AuthController.ts +69 -0
- package/templates/api/app/controllers/WelcomeController.ts +10 -0
- package/templates/api/app/middleware/RequireAuth.ts +10 -0
- package/templates/api/app/models/User.ts +16 -0
- package/templates/api/bootstrap/app.ts +19 -0
- package/templates/api/config/app.ts +8 -0
- package/templates/api/config/database.ts +6 -0
- package/templates/api/config/notifications.ts +13 -0
- package/templates/api/config/queue.ts +6 -0
- package/templates/api/config/session.ts +10 -0
- package/templates/api/database/migrations/0001_create_users_table.ts +18 -0
- package/templates/api/package.json +16 -0
- package/templates/api/routes/index.ts +13 -0
- package/templates/api/tests/README.md +77 -0
- package/templates/api/tests/auth.test.ts +134 -0
- package/templates/api/tests/helpers.ts +36 -0
- package/templates/api/tsconfig.json +14 -0
- package/templates/api/zt.ts +16 -0
- package/templates/flow/_env.example +2 -0
- package/templates/flow/_gitignore +6 -0
- package/templates/flow/app/flow/layouts/app.tsx +52 -0
- package/templates/flow/app/flow/pages/about.tsx +35 -0
- package/templates/flow/app/flow/pages/contact.tsx +47 -0
- package/templates/flow/app/flow/pages/index.tsx +48 -0
- package/templates/flow/bootstrap/app.ts +8 -0
- package/templates/flow/bootstrap/providers.ts +6 -0
- package/templates/flow/config/app.ts +28 -0
- package/templates/flow/package.json +20 -0
- package/templates/flow/public/zt.svg +17 -0
- package/templates/flow/resources/css/app.css +1 -0
- package/templates/flow/resources/js/app.ts +10 -0
- package/templates/flow/resources/js/env.d.ts +23 -0
- package/templates/flow/tests/smoke.test.ts +56 -0
- package/templates/flow/tsconfig.json +18 -0
- package/templates/flow/zt.ts +15 -0
- package/templates/minimal/_env.example +2 -0
- package/templates/minimal/_gitignore +6 -0
- package/templates/minimal/bootstrap/app.ts +9 -0
- package/templates/minimal/config/app.ts +28 -0
- package/templates/minimal/package.json +20 -0
- package/templates/minimal/resources/css/app.css +1 -0
- package/templates/minimal/resources/js/app.ts +2 -0
- package/templates/minimal/resources/js/components/Logo.tsx +48 -0
- package/templates/minimal/resources/js/env.d.ts +13 -0
- package/templates/minimal/resources/js/layouts/AppLayout.tsx +56 -0
- package/templates/minimal/resources/js/pages/about.tsx +30 -0
- package/templates/minimal/resources/js/pages/contact.tsx +49 -0
- package/templates/minimal/resources/js/pages/welcome.tsx +42 -0
- package/templates/minimal/routes/index.ts +16 -0
- package/templates/minimal/tests/smoke.test.ts +51 -0
- package/templates/minimal/tsconfig.json +19 -0
- package/templates/minimal/zt.ts +15 -0
- package/templates/react/_env.example +4 -0
- package/templates/react/_gitignore +5 -0
- package/templates/react/app/exceptions/Handler.ts +55 -0
- package/templates/react/app/routes/about.ts +8 -0
- package/templates/react/app/routes/contact.ts +31 -0
- package/templates/react/app/routes/index.ts +11 -0
- package/templates/react/bootstrap/app.ts +12 -0
- package/templates/react/bootstrap/providers.ts +10 -0
- package/templates/react/config/app.ts +25 -0
- package/templates/react/config/inertia.ts +7 -0
- package/templates/react/config/session.ts +11 -0
- package/templates/react/package.json +26 -0
- package/templates/react/public/zt.svg +17 -0
- package/templates/react/resources/app.html +39 -0
- package/templates/react/resources/css/app.css +206 -0
- package/templates/react/resources/js/Components/Button.tsx +67 -0
- package/templates/react/resources/js/Components/Card.tsx +40 -0
- package/templates/react/resources/js/Components/Code.tsx +33 -0
- package/templates/react/resources/js/Components/Field.tsx +125 -0
- package/templates/react/resources/js/Components/FlashToasts.tsx +79 -0
- package/templates/react/resources/js/Components/Icons.tsx +149 -0
- package/templates/react/resources/js/Components/ThemeToggle.tsx +47 -0
- package/templates/react/resources/js/Layouts/AppLayout.tsx +163 -0
- package/templates/react/resources/js/app.tsx +29 -0
- package/templates/react/resources/js/env.d.ts +13 -0
- package/templates/react/resources/js/lib/cn.ts +13 -0
- package/templates/react/resources/js/lib/site.ts +11 -0
- package/templates/react/resources/js/pages/about.tsx +146 -0
- package/templates/react/resources/js/pages/contact.tsx +152 -0
- package/templates/react/resources/js/pages/error.tsx +91 -0
- package/templates/react/resources/js/pages/home.tsx +199 -0
- package/templates/react/resources/js/pages.generated.ts +13 -0
- package/templates/react/resources/js/types.ts +17 -0
- package/templates/react/tests/smoke.test.ts +69 -0
- package/templates/react/tsconfig.json +21 -0
- package/templates/react/zt.ts +15 -0
- package/templates/vue/_env.example +4 -0
- package/templates/vue/_gitignore +5 -0
- package/templates/vue/app/routes/about.ts +7 -0
- package/templates/vue/app/routes/contact.ts +7 -0
- package/templates/vue/app/routes/index.ts +8 -0
- package/templates/vue/bootstrap/app.ts +8 -0
- package/templates/vue/bootstrap/providers.ts +6 -0
- package/templates/vue/config/app.ts +25 -0
- package/templates/vue/config/inertia.ts +7 -0
- package/templates/vue/package.json +24 -0
- package/templates/vue/resources/app.html +13 -0
- package/templates/vue/resources/css/app.css +1 -0
- package/templates/vue/resources/js/Layouts/AppLayout.vue +63 -0
- package/templates/vue/resources/js/app.tsx +19 -0
- package/templates/vue/resources/js/env.d.ts +20 -0
- package/templates/vue/resources/js/pages/about.vue +41 -0
- package/templates/vue/resources/js/pages/contact.vue +54 -0
- package/templates/vue/resources/js/pages/home.vue +54 -0
- package/templates/vue/resources/js/pages.generated.ts +12 -0
- package/templates/vue/tests/smoke.test.ts +46 -0
- package/templates/vue/tsconfig.json +21 -0
- package/templates/vue/zt.ts +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog â @zerotal/create-zerotal
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## [1.0.0] — 2026-08-05
|
|
13
|
+
|
|
14
|
+
_First public release._
|
|
15
|
+
|
|
16
|
+
### Notes
|
|
17
|
+
- Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# create-zerotal
|
|
2
|
+
|
|
3
|
+
> Scaffold a new Zerotal application in seconds with `bun create zerotal`.
|
|
4
|
+
|
|
5
|
+
`create-zerotal` is the interactive scaffolding CLI for the Zerotal framework. It prompts for a project name, a starter template, and (for the API template) a database, then generates the project and runs `bun install`.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
> **Bun is required.** The CLI entry (`src/index.ts`) uses a `#!/usr/bin/env bun` shebang and the Bun runtime APIs (`Bun.file`, `Bun.env`). It cannot run under Node — `npx create-zerotal` / `npm create zerotal` will fail; use `bun create zerotal`.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun create zerotal my-app
|
|
15
|
+
# or
|
|
16
|
+
bunx create-zerotal my-app
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The CLI is interactive: if you omit the project name it prompts for one (default `my-zerotal-app`), then asks you to choose a template and, for the `api` template, a database. It aborts rather than overwrite a directory that already contains a `package.json`.
|
|
20
|
+
|
|
21
|
+
### Templates
|
|
22
|
+
|
|
23
|
+
| Template | Description |
|
|
24
|
+
| --------- | ----------------------------------------------------------------------------- |
|
|
25
|
+
| `api` | JSON REST API — core, ORM, auth, validation, testing. Prompts for a database. |
|
|
26
|
+
| `admin` | Admin panel — resources, auth, dashboard widgets, seeded demo data. |
|
|
27
|
+
| `flow` | Server-driven UI — Flow pages, top nav, Tailwind. |
|
|
28
|
+
| `react` | Inertia + React SPA — file-based routes, Tailwind. |
|
|
29
|
+
| `vue` | Inertia + Vue SPA — file-based routes, Tailwind. |
|
|
30
|
+
| `minimal` | Single page, JSX views, Tailwind — the bare framework. |
|
|
31
|
+
|
|
32
|
+
### Databases (api template only)
|
|
33
|
+
|
|
34
|
+
| Choice | Notes |
|
|
35
|
+
| ---------- | --------------------------------------------------- |
|
|
36
|
+
| `sqlite` | Zero setup, file-based — great for getting started. |
|
|
37
|
+
| `postgres` | Requires a `DATABASE_URL` env var. |
|
|
38
|
+
| `mysql` | Requires a `DATABASE_URL` env var. |
|
|
39
|
+
|
|
40
|
+
### Next steps
|
|
41
|
+
|
|
42
|
+
After scaffolding, the CLI prints the next commands:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd my-app
|
|
46
|
+
# (set DATABASE_URL in .env for a non-sqlite api project)
|
|
47
|
+
bun zt migrate # api template only
|
|
48
|
+
bun zt db:seed # admin template — demo data plus the first account
|
|
49
|
+
bun zt serve --dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `admin` template's panel is behind a sign-in, so seeding is what makes a fresh
|
|
53
|
+
app usable rather than a locked door: it creates `admin@example.com` with the
|
|
54
|
+
password `password`, alongside a small demo catalogue. Open `/admin`.
|
|
55
|
+
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
- [Scaffolding](../../docs/scaffolding.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-zerotal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create a new Zerotal application",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"maturity": "stable",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-zerotal": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"src",
|
|
14
|
+
"templates",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"!src/**/__fixtures__/**"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.3.14"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.8.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"zerotal",
|
|
29
|
+
"bun",
|
|
30
|
+
"typescript",
|
|
31
|
+
"framework",
|
|
32
|
+
"scaffold",
|
|
33
|
+
"cli",
|
|
34
|
+
"create"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
39
|
+
"directory": "packages/create-zerotal"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/create-zerotal#readme",
|
|
42
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* create-zerotal — Zerotal application scaffolder
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun create zerotal my-app
|
|
7
|
+
* bunx create-zerotal my-app
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { printBanner, ask, choose, log, info, warn, step, dim, c } from './prompts.ts';
|
|
12
|
+
import { scaffold, install, type Template, type Database } from './scaffold.ts';
|
|
13
|
+
|
|
14
|
+
async function main(): Promise<void> {
|
|
15
|
+
printBanner();
|
|
16
|
+
|
|
17
|
+
// ── Project name ────────────────────────────────────────────────────────────
|
|
18
|
+
const nameArg = process.argv[2]?.trim() ?? '';
|
|
19
|
+
const name = nameArg
|
|
20
|
+
|| await ask('Project name', 'my-zerotal-app');
|
|
21
|
+
|
|
22
|
+
const target = resolve(process.cwd(), name);
|
|
23
|
+
|
|
24
|
+
// Guard: don't overwrite an existing non-empty directory
|
|
25
|
+
const existing = await Bun.file(target + '/package.json').exists();
|
|
26
|
+
if (existing) {
|
|
27
|
+
warn(`Directory already contains a package.json — aborting to avoid overwrite.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Template ────────────────────────────────────────────────────────────────
|
|
32
|
+
log('');
|
|
33
|
+
const template = await choose<Template>('Template', [
|
|
34
|
+
{
|
|
35
|
+
value: 'api',
|
|
36
|
+
label: 'API',
|
|
37
|
+
description: 'JSON REST API — core, ORM, auth, validation, testing',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
value: 'admin',
|
|
41
|
+
label: 'Admin',
|
|
42
|
+
description: 'Admin panel — resources, auth, dashboard widgets, seeded demo data',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
value: 'flow',
|
|
46
|
+
label: 'Flow',
|
|
47
|
+
description: 'Server-driven UI — Flow pages, top nav, Tailwind',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
value: 'react',
|
|
51
|
+
label: 'React',
|
|
52
|
+
description: 'Inertia + React SPA — file-based routes, Tailwind',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
value: 'vue',
|
|
56
|
+
label: 'Vue',
|
|
57
|
+
description: 'Inertia + Vue SPA — file-based routes, Tailwind',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
value: 'minimal',
|
|
61
|
+
label: 'Minimal',
|
|
62
|
+
description: 'Single page, JSX views, Tailwind — bare framework',
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// ── Database ─────────────────────────────────────────────────────────────────
|
|
67
|
+
// Only the API template ships a database config; minimal/flow have no DB.
|
|
68
|
+
let db: Database = 'sqlite';
|
|
69
|
+
if (template === 'api') {
|
|
70
|
+
log('');
|
|
71
|
+
db = await choose<Database>('Database', [
|
|
72
|
+
{
|
|
73
|
+
value: 'sqlite',
|
|
74
|
+
label: 'SQLite',
|
|
75
|
+
description: 'Zero setup, file-based — great for getting started',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
value: 'postgres',
|
|
79
|
+
label: 'PostgreSQL',
|
|
80
|
+
description: 'Requires DATABASE_URL env var',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
value: 'mysql',
|
|
84
|
+
label: 'MySQL',
|
|
85
|
+
description: 'Requires DATABASE_URL env var',
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Scaffold ────────────────────────────────────────────────────────────────
|
|
91
|
+
log('');
|
|
92
|
+
step(`Scaffolding ${c.bold}${name}${c.reset} (${template})…`);
|
|
93
|
+
|
|
94
|
+
await scaffold({ name, template, db, target });
|
|
95
|
+
info(`Project files created`);
|
|
96
|
+
|
|
97
|
+
// ── Install ─────────────────────────────────────────────────────────────────
|
|
98
|
+
log('');
|
|
99
|
+
step('Installing dependencies…');
|
|
100
|
+
const ok = await install(target);
|
|
101
|
+
if (ok) {
|
|
102
|
+
info(`Dependencies installed`);
|
|
103
|
+
} else {
|
|
104
|
+
warn(`bun install failed — run it manually inside the project`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Done ─────────────────────────────────────────────────────────────────────
|
|
108
|
+
log('');
|
|
109
|
+
log(`${c.green}${c.bold} ✓ Done!${c.reset}`);
|
|
110
|
+
log('');
|
|
111
|
+
log(`${c.bold} Next steps:${c.reset}`);
|
|
112
|
+
dim(`cd ${name}`);
|
|
113
|
+
if (template === 'api') {
|
|
114
|
+
if (db !== 'sqlite') {
|
|
115
|
+
dim(`# Set DATABASE_URL in .env`);
|
|
116
|
+
}
|
|
117
|
+
dim(`bun zt migrate`);
|
|
118
|
+
}
|
|
119
|
+
if (template === 'admin') {
|
|
120
|
+
// The panel is behind a sign-in, so the demo data (which includes the first
|
|
121
|
+
// account) is what makes a fresh app usable rather than a locked door.
|
|
122
|
+
dim(`bun zt db:seed # demo data + admin@example.com / password`);
|
|
123
|
+
}
|
|
124
|
+
dim(`bun zt serve --dev`);
|
|
125
|
+
if (template === 'admin') {
|
|
126
|
+
dim(`# then open http://localhost:3000/admin`);
|
|
127
|
+
}
|
|
128
|
+
log('');
|
|
129
|
+
log(`${c.gray} Docs: https://zerotal.dev${c.reset}`);
|
|
130
|
+
log('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
main().catch((err: unknown) => {
|
|
134
|
+
process.stderr.write(`\x1b[31mError: ${(err as Error).message}\x1b[0m\n`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
// ── ANSI helpers ──────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const c = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
bold: '\x1b[1m',
|
|
8
|
+
dim: '\x1b[2m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
cyan: '\x1b[36m',
|
|
11
|
+
yellow: '\x1b[33m',
|
|
12
|
+
blue: '\x1b[34m',
|
|
13
|
+
gray: '\x1b[90m',
|
|
14
|
+
white: '\x1b[97m',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function log(msg: string) { process.stdout.write(msg + '\n'); }
|
|
18
|
+
export function info(msg: string) { log(`${c.green} ✓${c.reset} ${msg}`); }
|
|
19
|
+
export function warn(msg: string) { log(`${c.yellow} ⚠${c.reset} ${msg}`); }
|
|
20
|
+
export function step(msg: string) { log(`${c.cyan} ◆${c.reset} ${c.bold}${msg}${c.reset}`); }
|
|
21
|
+
export function dim(msg: string) { log(`${c.gray} ${msg}${c.reset}`); }
|
|
22
|
+
|
|
23
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function printBanner(): void {
|
|
26
|
+
log('');
|
|
27
|
+
log(`${c.bold}${c.white} ██╗ ██╗██╗ ██╗██╗ █████╗ ███╗ ██╗██╗${c.reset}`);
|
|
28
|
+
log(`${c.bold}${c.white} ██║ ██╔╝██║ ██║██║ ██╔══██╗████╗ ██║██║${c.reset}`);
|
|
29
|
+
log(`${c.bold}${c.cyan} █████╔╝ ██║ ██║██║ ███████║██╔██╗ ██║██║${c.reset}`);
|
|
30
|
+
log(`${c.bold}${c.cyan} ██╔═██╗ ██║ ██║██║ ██╔══██║██║╚██╗██║██║${c.reset}`);
|
|
31
|
+
log(`${c.bold}${c.blue} ██║ ██╗╚██████╔╝███████╗██║ ██║██║ ╚████║██║${c.reset}`);
|
|
32
|
+
log(`${c.bold}${c.blue} ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝${c.reset}`);
|
|
33
|
+
log('');
|
|
34
|
+
log(`${c.gray} Bun-native TypeScript framework${c.reset}`);
|
|
35
|
+
log('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Prompt primitives ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function rl(): ReturnType<typeof createInterface> {
|
|
41
|
+
return createInterface({
|
|
42
|
+
input: process.stdin,
|
|
43
|
+
output: process.stdout,
|
|
44
|
+
terminal: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ask(question: string, defaultValue = ''): Promise<string> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const hint = defaultValue ? ` ${c.gray}(${defaultValue})${c.reset}` : '';
|
|
51
|
+
process.stdout.write(` ${c.cyan}?${c.reset} ${question}${hint} `);
|
|
52
|
+
const iface = rl();
|
|
53
|
+
iface.once('line', (line) => {
|
|
54
|
+
iface.close();
|
|
55
|
+
resolve(line.trim() || defaultValue);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function choose<T extends string>(
|
|
61
|
+
question: string,
|
|
62
|
+
options: Array<{ value: T; label: string; description?: string }>,
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
log(` ${c.cyan}?${c.reset} ${question}`);
|
|
66
|
+
options.forEach(({ label, description }, i) => {
|
|
67
|
+
const idx = `${c.gray}[${i + 1}]${c.reset}`;
|
|
68
|
+
const desc = description ? ` ${c.gray}— ${description}${c.reset}` : '';
|
|
69
|
+
log(` ${idx} ${label}${desc}`);
|
|
70
|
+
});
|
|
71
|
+
process.stdout.write(` ${c.gray}Enter number (default 1):${c.reset} `);
|
|
72
|
+
|
|
73
|
+
const iface = rl();
|
|
74
|
+
iface.once('line', (line) => {
|
|
75
|
+
iface.close();
|
|
76
|
+
const n = parseInt(line.trim(), 10);
|
|
77
|
+
const idx = isNaN(n) || n < 1 || n > options.length ? 0 : n - 1;
|
|
78
|
+
resolve(options[idx]!.value);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export type Database = 'sqlite' | 'postgres' | 'mysql';
|
|
5
|
+
export type Template = 'minimal' | 'api' | 'admin' | 'flow' | 'react' | 'vue';
|
|
6
|
+
|
|
7
|
+
// Version range stamped into scaffolded apps' `zerotal` / `@zerotal/*` dependencies.
|
|
8
|
+
// Tracks the published framework release — bump this when the packages are
|
|
9
|
+
// versioned for a release (see AUDIT_2 "Set real versions").
|
|
10
|
+
export const ZT_VERSION = '^1.1.0';
|
|
11
|
+
|
|
12
|
+
export interface ScaffoldOptions {
|
|
13
|
+
name: string;
|
|
14
|
+
template: Template;
|
|
15
|
+
db: Database;
|
|
16
|
+
target: string; // absolute path of the new project directory
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Token map ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function randomBase64(bytes: number): string {
|
|
22
|
+
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('base64');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tokens(opts: ScaffoldOptions): Record<string, string> {
|
|
26
|
+
const dbUrl: Record<Database, string> = {
|
|
27
|
+
sqlite: './database/db.sqlite',
|
|
28
|
+
postgres: 'postgres://localhost/{{name}}',
|
|
29
|
+
mysql: 'mysql://root@localhost/{{name}}',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
'{{name}}': opts.name,
|
|
34
|
+
'{{db_url}}': dbUrl[opts.db].replace('{{name}}', opts.name),
|
|
35
|
+
'{{app_key}}': randomBase64(32),
|
|
36
|
+
'{{session_secret}}': randomBase64(32),
|
|
37
|
+
'{{db_driver}}': opts.db,
|
|
38
|
+
'{{zerotal_version}}': ZT_VERSION,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyTokens(content: string, map: Record<string, string>): string {
|
|
43
|
+
let out = content;
|
|
44
|
+
for (const [token, value] of Object.entries(map)) {
|
|
45
|
+
out = out.replaceAll(token, value);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
53
|
+
const glob = new Bun.Glob('**/*');
|
|
54
|
+
for await (const rel of glob.scan({ cwd: dir, onlyFiles: true })) {
|
|
55
|
+
yield rel;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Scaffold ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export async function scaffold(opts: ScaffoldOptions): Promise<void> {
|
|
62
|
+
const templatesRoot = join(import.meta.dir, '..', 'templates');
|
|
63
|
+
const templateDir = join(templatesRoot, opts.template);
|
|
64
|
+
const map = tokens(opts);
|
|
65
|
+
|
|
66
|
+
for await (const rel of walk(templateDir)) {
|
|
67
|
+
const src = join(templateDir, rel);
|
|
68
|
+
// Rename underscore-prefixed dotfiles — npm and Bun glob both skip real dotfiles
|
|
69
|
+
// inside published/scanned directories. _gitignore → .gitignore, etc.
|
|
70
|
+
// Also strip the .tmpl infix from test files so Bun doesn't discover them
|
|
71
|
+
// as tests while the package is being developed in the monorepo.
|
|
72
|
+
const dest = join(opts.target, rel
|
|
73
|
+
.replace('_gitignore', '.gitignore')
|
|
74
|
+
.replace('_env.example', '.env.example')
|
|
75
|
+
.replace('_test.ts', '.test.ts'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
79
|
+
|
|
80
|
+
const raw = await Bun.file(src).text();
|
|
81
|
+
const content = applyTokens(raw, map);
|
|
82
|
+
await Bun.write(dest, content);
|
|
83
|
+
|
|
84
|
+
// Also write a ready-to-run `.env` next to `.env.example`. The example
|
|
85
|
+
// already carries a freshly-generated APP_KEY/SESSION_SECRET, so a fresh
|
|
86
|
+
// app boots immediately without a manual `cp .env.example .env` +
|
|
87
|
+
// `key:generate` step (the old flow left APP_KEY empty on first boot).
|
|
88
|
+
if (dest.endsWith('.env.example')) {
|
|
89
|
+
await Bun.write(dest.replace(/\.env\.example$/, '.env'), content);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Install ───────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export async function install(cwd: string): Promise<boolean> {
|
|
97
|
+
const proc = Bun.spawn(['bun', 'install'], {
|
|
98
|
+
cwd,
|
|
99
|
+
stdout: 'inherit',
|
|
100
|
+
stderr: 'inherit',
|
|
101
|
+
});
|
|
102
|
+
const code = await proc.exited;
|
|
103
|
+
return code === 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Resource,
|
|
3
|
+
text,
|
|
4
|
+
toggleColumn,
|
|
5
|
+
selectColumn,
|
|
6
|
+
textInput,
|
|
7
|
+
textarea,
|
|
8
|
+
toggle,
|
|
9
|
+
select,
|
|
10
|
+
formSection,
|
|
11
|
+
section,
|
|
12
|
+
textEntry,
|
|
13
|
+
iconEntry,
|
|
14
|
+
tab,
|
|
15
|
+
queryBuilder,
|
|
16
|
+
textConstraint,
|
|
17
|
+
numberConstraint,
|
|
18
|
+
selectConstraint,
|
|
19
|
+
booleanConstraint,
|
|
20
|
+
action,
|
|
21
|
+
actionGroup,
|
|
22
|
+
viewAction,
|
|
23
|
+
editAction,
|
|
24
|
+
deleteAction,
|
|
25
|
+
replicateAction,
|
|
26
|
+
createAction,
|
|
27
|
+
exportAction,
|
|
28
|
+
importAction,
|
|
29
|
+
bulkExportAction,
|
|
30
|
+
bulkDeleteAction,
|
|
31
|
+
} from "@zerotal/admin";
|
|
32
|
+
import type { AdminRecord } from "@zerotal/admin";
|
|
33
|
+
import { Product } from "@app/models/Product";
|
|
34
|
+
import { money } from "@app/admin/shared";
|
|
35
|
+
|
|
36
|
+
const STATUS = { draft: "Draft", active: "Active", discontinued: "Discontinued" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The catalogue — the resource to copy when you add your own.
|
|
40
|
+
*
|
|
41
|
+
* It shows the parts of a table that most resources end up wanting: filter tabs
|
|
42
|
+
* with live counts, a build-your-own filter, footer summaries, an overflow menu
|
|
43
|
+
* for the occasional actions, CSV import/export, and soft deletes.
|
|
44
|
+
*/
|
|
45
|
+
export class ProductResource extends Resource {
|
|
46
|
+
static override model = Product;
|
|
47
|
+
static override navigationIcon = "collection";
|
|
48
|
+
static override navigationSort = 1;
|
|
49
|
+
static override recordTitleAttribute = "name";
|
|
50
|
+
static override defaultSort = { column: "created_at", direction: "desc" as const };
|
|
51
|
+
|
|
52
|
+
/** A count beside the sidebar entry. Cached, and refreshed on every write. */
|
|
53
|
+
static override navigationBadge() {
|
|
54
|
+
return this.count((q) => q.where("status", "active"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static override tabs() {
|
|
58
|
+
return [
|
|
59
|
+
tab("all").label("All").badge(),
|
|
60
|
+
tab("active")
|
|
61
|
+
.label("Active")
|
|
62
|
+
.badge()
|
|
63
|
+
.badgeColor("success")
|
|
64
|
+
.modifyQuery((q) => q.where("status", "active")),
|
|
65
|
+
tab("draft")
|
|
66
|
+
.label("Drafts")
|
|
67
|
+
.badge()
|
|
68
|
+
.badgeColor("muted")
|
|
69
|
+
.modifyQuery((q) => q.where("status", "draft")),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static override filters() {
|
|
74
|
+
return [
|
|
75
|
+
queryBuilder("q")
|
|
76
|
+
.label("Advanced filter")
|
|
77
|
+
.constraints([
|
|
78
|
+
textConstraint("name"),
|
|
79
|
+
textConstraint("sku").label("SKU"),
|
|
80
|
+
numberConstraint("price").label("Price (cents)"),
|
|
81
|
+
numberConstraint("stock"),
|
|
82
|
+
selectConstraint("status").options(STATUS),
|
|
83
|
+
booleanConstraint("featured"),
|
|
84
|
+
]),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static override headerActions() {
|
|
89
|
+
return [createAction(), exportAction(), importAction()];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static override bulkActions() {
|
|
93
|
+
return [bulkExportAction(), bulkDeleteAction()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static override recordActions() {
|
|
97
|
+
return [
|
|
98
|
+
viewAction(),
|
|
99
|
+
editAction(),
|
|
100
|
+
// Occasional and destructive actions go behind one menu, so the row keeps
|
|
101
|
+
// two buttons rather than five.
|
|
102
|
+
actionGroup([
|
|
103
|
+
replicateAction()
|
|
104
|
+
.excludeAttributes(["sku"])
|
|
105
|
+
.beforeReplicaSaved((data) => ({
|
|
106
|
+
...data,
|
|
107
|
+
name: `${String(data["name"])} (copy)`,
|
|
108
|
+
status: "draft",
|
|
109
|
+
})),
|
|
110
|
+
action("feature")
|
|
111
|
+
.label("Feature")
|
|
112
|
+
.icon("plus")
|
|
113
|
+
.visible((rec) => !(rec as unknown as Product)?.featured)
|
|
114
|
+
.run(async ({ record, resource }) => {
|
|
115
|
+
await resource.update(record!.id, { featured: true });
|
|
116
|
+
})
|
|
117
|
+
.successMessage("Product featured."),
|
|
118
|
+
deleteAction(),
|
|
119
|
+
]).label("More"),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static override columns() {
|
|
124
|
+
return [
|
|
125
|
+
text("name").searchable().sortable(),
|
|
126
|
+
text("sku").label("SKU").searchable().copyable(),
|
|
127
|
+
text("price")
|
|
128
|
+
.sortable()
|
|
129
|
+
.align("end")
|
|
130
|
+
.format((v) => money(v))
|
|
131
|
+
.sum("Total value", (n) => money(n)),
|
|
132
|
+
text("stock").sortable().align("end").sum("Units"),
|
|
133
|
+
selectColumn("status", STATUS).label("Status"),
|
|
134
|
+
toggleColumn("featured").label("Featured"),
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
static override form() {
|
|
139
|
+
return [
|
|
140
|
+
formSection("Product")
|
|
141
|
+
.columns(2)
|
|
142
|
+
.schema([
|
|
143
|
+
textInput("name").required().minLength(2).maxLength(120).columnSpan(2),
|
|
144
|
+
textInput("sku").label("SKU").required().maxLength(40),
|
|
145
|
+
select("status").options(STATUS).default("draft").required(),
|
|
146
|
+
textInput("price")
|
|
147
|
+
.label("Price (cents)")
|
|
148
|
+
.numeric()
|
|
149
|
+
.required()
|
|
150
|
+
.min(0)
|
|
151
|
+
.helperText("Stored in minor units so money never rides on a float."),
|
|
152
|
+
textInput("stock").numeric().min(0).default(0),
|
|
153
|
+
textarea("description").rows(4).columnSpan(2),
|
|
154
|
+
toggle("featured").label("Featured").columnSpan(2),
|
|
155
|
+
]),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
static override infolist() {
|
|
160
|
+
return [
|
|
161
|
+
section("Product")
|
|
162
|
+
.icon("collection")
|
|
163
|
+
.columns(2)
|
|
164
|
+
.schema([
|
|
165
|
+
textEntry("name").weight("semibold").size("lg"),
|
|
166
|
+
textEntry("sku").label("SKU").copyable(),
|
|
167
|
+
textEntry("price").format((v) => money(v)),
|
|
168
|
+
textEntry("stock"),
|
|
169
|
+
textEntry("status")
|
|
170
|
+
.badge()
|
|
171
|
+
.color((v) => (v === "active" ? "success" : "muted")),
|
|
172
|
+
iconEntry("featured").label("Featured"),
|
|
173
|
+
textEntry("description").columnSpan(2).placeholder("(no description)"),
|
|
174
|
+
]),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Runs on every save, after validation and before the write. */
|
|
179
|
+
static override mutateBeforeSave(data: AdminRecord): AdminRecord {
|
|
180
|
+
if (typeof data["sku"] === "string") data["sku"] = data["sku"].toUpperCase();
|
|
181
|
+
return data;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
static override emptyState() {
|
|
185
|
+
return {
|
|
186
|
+
heading: "No products yet",
|
|
187
|
+
description: "Add your first product, or import a catalogue from a CSV file.",
|
|
188
|
+
icon: "collection",
|
|
189
|
+
actions: [createAction(), importAction()],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|