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.
Files changed (142) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/package.json +43 -0
  5. package/src/index.ts +136 -0
  6. package/src/prompts.ts +81 -0
  7. package/src/scaffold.ts +104 -0
  8. package/templates/admin/_env.example +2 -0
  9. package/templates/admin/_gitignore +4 -0
  10. package/templates/admin/app/admin/ProductResource.ts +192 -0
  11. package/templates/admin/app/admin/SettingsResource.ts +37 -0
  12. package/templates/admin/app/admin/UserResource.ts +103 -0
  13. package/templates/admin/app/admin/index.ts +46 -0
  14. package/templates/admin/app/admin/shared.ts +36 -0
  15. package/templates/admin/app/admin/widgets.ts +51 -0
  16. package/templates/admin/app/models/Product.ts +21 -0
  17. package/templates/admin/app/models/Setting.ts +14 -0
  18. package/templates/admin/app/models/User.ts +20 -0
  19. package/templates/admin/bootstrap/app.ts +16 -0
  20. package/templates/admin/bootstrap/providers.ts +23 -0
  21. package/templates/admin/config/app.ts +22 -0
  22. package/templates/admin/config/auth.ts +5 -0
  23. package/templates/admin/config/cache.ts +7 -0
  24. package/templates/admin/config/database.ts +14 -0
  25. package/templates/admin/config/session.ts +10 -0
  26. package/templates/admin/database/seeders/DatabaseSeeder.ts +50 -0
  27. package/templates/admin/package.json +18 -0
  28. package/templates/admin/tests/admin_test.ts +58 -0
  29. package/templates/admin/tsconfig.json +20 -0
  30. package/templates/admin/zt.ts +15 -0
  31. package/templates/api/_env.example +4 -0
  32. package/templates/api/_gitignore +5 -0
  33. package/templates/api/app/controllers/AuthController.ts +69 -0
  34. package/templates/api/app/controllers/WelcomeController.ts +10 -0
  35. package/templates/api/app/middleware/RequireAuth.ts +10 -0
  36. package/templates/api/app/models/User.ts +16 -0
  37. package/templates/api/bootstrap/app.ts +19 -0
  38. package/templates/api/config/app.ts +8 -0
  39. package/templates/api/config/database.ts +6 -0
  40. package/templates/api/config/notifications.ts +13 -0
  41. package/templates/api/config/queue.ts +6 -0
  42. package/templates/api/config/session.ts +10 -0
  43. package/templates/api/database/migrations/0001_create_users_table.ts +18 -0
  44. package/templates/api/package.json +16 -0
  45. package/templates/api/routes/index.ts +13 -0
  46. package/templates/api/tests/README.md +77 -0
  47. package/templates/api/tests/auth.test.ts +134 -0
  48. package/templates/api/tests/helpers.ts +36 -0
  49. package/templates/api/tsconfig.json +14 -0
  50. package/templates/api/zt.ts +16 -0
  51. package/templates/flow/_env.example +2 -0
  52. package/templates/flow/_gitignore +6 -0
  53. package/templates/flow/app/flow/layouts/app.tsx +52 -0
  54. package/templates/flow/app/flow/pages/about.tsx +35 -0
  55. package/templates/flow/app/flow/pages/contact.tsx +47 -0
  56. package/templates/flow/app/flow/pages/index.tsx +48 -0
  57. package/templates/flow/bootstrap/app.ts +8 -0
  58. package/templates/flow/bootstrap/providers.ts +6 -0
  59. package/templates/flow/config/app.ts +28 -0
  60. package/templates/flow/package.json +20 -0
  61. package/templates/flow/public/zt.svg +17 -0
  62. package/templates/flow/resources/css/app.css +1 -0
  63. package/templates/flow/resources/js/app.ts +10 -0
  64. package/templates/flow/resources/js/env.d.ts +23 -0
  65. package/templates/flow/tests/smoke.test.ts +56 -0
  66. package/templates/flow/tsconfig.json +18 -0
  67. package/templates/flow/zt.ts +15 -0
  68. package/templates/minimal/_env.example +2 -0
  69. package/templates/minimal/_gitignore +6 -0
  70. package/templates/minimal/bootstrap/app.ts +9 -0
  71. package/templates/minimal/config/app.ts +28 -0
  72. package/templates/minimal/package.json +20 -0
  73. package/templates/minimal/resources/css/app.css +1 -0
  74. package/templates/minimal/resources/js/app.ts +2 -0
  75. package/templates/minimal/resources/js/components/Logo.tsx +48 -0
  76. package/templates/minimal/resources/js/env.d.ts +13 -0
  77. package/templates/minimal/resources/js/layouts/AppLayout.tsx +56 -0
  78. package/templates/minimal/resources/js/pages/about.tsx +30 -0
  79. package/templates/minimal/resources/js/pages/contact.tsx +49 -0
  80. package/templates/minimal/resources/js/pages/welcome.tsx +42 -0
  81. package/templates/minimal/routes/index.ts +16 -0
  82. package/templates/minimal/tests/smoke.test.ts +51 -0
  83. package/templates/minimal/tsconfig.json +19 -0
  84. package/templates/minimal/zt.ts +15 -0
  85. package/templates/react/_env.example +4 -0
  86. package/templates/react/_gitignore +5 -0
  87. package/templates/react/app/exceptions/Handler.ts +55 -0
  88. package/templates/react/app/routes/about.ts +8 -0
  89. package/templates/react/app/routes/contact.ts +31 -0
  90. package/templates/react/app/routes/index.ts +11 -0
  91. package/templates/react/bootstrap/app.ts +12 -0
  92. package/templates/react/bootstrap/providers.ts +10 -0
  93. package/templates/react/config/app.ts +25 -0
  94. package/templates/react/config/inertia.ts +7 -0
  95. package/templates/react/config/session.ts +11 -0
  96. package/templates/react/package.json +26 -0
  97. package/templates/react/public/zt.svg +17 -0
  98. package/templates/react/resources/app.html +39 -0
  99. package/templates/react/resources/css/app.css +206 -0
  100. package/templates/react/resources/js/Components/Button.tsx +67 -0
  101. package/templates/react/resources/js/Components/Card.tsx +40 -0
  102. package/templates/react/resources/js/Components/Code.tsx +33 -0
  103. package/templates/react/resources/js/Components/Field.tsx +125 -0
  104. package/templates/react/resources/js/Components/FlashToasts.tsx +79 -0
  105. package/templates/react/resources/js/Components/Icons.tsx +149 -0
  106. package/templates/react/resources/js/Components/ThemeToggle.tsx +47 -0
  107. package/templates/react/resources/js/Layouts/AppLayout.tsx +163 -0
  108. package/templates/react/resources/js/app.tsx +29 -0
  109. package/templates/react/resources/js/env.d.ts +13 -0
  110. package/templates/react/resources/js/lib/cn.ts +13 -0
  111. package/templates/react/resources/js/lib/site.ts +11 -0
  112. package/templates/react/resources/js/pages/about.tsx +146 -0
  113. package/templates/react/resources/js/pages/contact.tsx +152 -0
  114. package/templates/react/resources/js/pages/error.tsx +91 -0
  115. package/templates/react/resources/js/pages/home.tsx +199 -0
  116. package/templates/react/resources/js/pages.generated.ts +13 -0
  117. package/templates/react/resources/js/types.ts +17 -0
  118. package/templates/react/tests/smoke.test.ts +69 -0
  119. package/templates/react/tsconfig.json +21 -0
  120. package/templates/react/zt.ts +15 -0
  121. package/templates/vue/_env.example +4 -0
  122. package/templates/vue/_gitignore +5 -0
  123. package/templates/vue/app/routes/about.ts +7 -0
  124. package/templates/vue/app/routes/contact.ts +7 -0
  125. package/templates/vue/app/routes/index.ts +8 -0
  126. package/templates/vue/bootstrap/app.ts +8 -0
  127. package/templates/vue/bootstrap/providers.ts +6 -0
  128. package/templates/vue/config/app.ts +25 -0
  129. package/templates/vue/config/inertia.ts +7 -0
  130. package/templates/vue/package.json +24 -0
  131. package/templates/vue/resources/app.html +13 -0
  132. package/templates/vue/resources/css/app.css +1 -0
  133. package/templates/vue/resources/js/Layouts/AppLayout.vue +63 -0
  134. package/templates/vue/resources/js/app.tsx +19 -0
  135. package/templates/vue/resources/js/env.d.ts +20 -0
  136. package/templates/vue/resources/js/pages/about.vue +41 -0
  137. package/templates/vue/resources/js/pages/contact.vue +54 -0
  138. package/templates/vue/resources/js/pages/home.vue +54 -0
  139. package/templates/vue/resources/js/pages.generated.ts +12 -0
  140. package/templates/vue/tests/smoke.test.ts +46 -0
  141. package/templates/vue/tsconfig.json +21 -0
  142. package/templates/vue/zt.ts +15 -0
@@ -0,0 +1,69 @@
1
+ import type { HttpContext } from '@zerotal/core';
2
+ import { Hash } from '@zerotal/auth';
3
+ import { User } from '../models/User.ts';
4
+
5
+ export class AuthController {
6
+ /** POST /register */
7
+ async register(http: HttpContext): Promise<void> {
8
+ const body = await http.request.json() as Record<string, string>;
9
+ const { name, email, password } = body;
10
+
11
+ if (!name || !email || !password) {
12
+ http.response = Response.json({ message: 'name, email, and password are required.' }, { status: 422 });
13
+ return;
14
+ }
15
+
16
+ const exists = await User.query().where('email', email).first();
17
+ if (exists) {
18
+ http.response = Response.json({ message: 'Email already in use.' }, { status: 422 });
19
+ return;
20
+ }
21
+
22
+ const user = await User.create({
23
+ name,
24
+ email,
25
+ password: await Hash.make(password),
26
+ role: 'user',
27
+ } as Partial<User>);
28
+
29
+ http.session?.set('user_id', user.id);
30
+ http.response = Response.json({ data: { id: user.id, name: user.name, email: user.email } }, { status: 201 });
31
+ }
32
+
33
+ /** POST /login */
34
+ async login(http: HttpContext): Promise<void> {
35
+ const body = await http.request.json() as Record<string, string>;
36
+ const email = body.email?.trim() ?? '';
37
+ const password = body.password ?? '';
38
+
39
+ if (!email || !password) {
40
+ http.response = Response.json({ message: 'email and password are required.' }, { status: 422 });
41
+ return;
42
+ }
43
+
44
+ const user = await User.query().where('email', email).first();
45
+ if (!user || !(await Hash.check(password, user.password))) {
46
+ http.response = Response.json({ message: 'Invalid credentials.' }, { status: 401 });
47
+ return;
48
+ }
49
+
50
+ http.session?.set('user_id', user.id);
51
+ http.response = Response.json({ data: { id: user.id, name: user.name, email: user.email } });
52
+ }
53
+
54
+ /** POST /logout */
55
+ async logout(http: HttpContext): Promise<void> {
56
+ http.session?.forget('user_id');
57
+ http.response = Response.json({ message: 'Logged out.' });
58
+ }
59
+
60
+ /** GET /me */
61
+ async me(http: HttpContext): Promise<void> {
62
+ if (!http.user) {
63
+ http.response = Response.json({ message: 'Unauthenticated.' }, { status: 401 });
64
+ return;
65
+ }
66
+ const user = await User.findOrFail(http.user.id as number);
67
+ http.response = Response.json({ data: { id: user.id, name: user.name, email: user.email } });
68
+ }
69
+ }
@@ -0,0 +1,10 @@
1
+ import type { HttpContext } from '@zerotal/core';
2
+
3
+ export class WelcomeController {
4
+ async index(http: HttpContext): Promise<void> {
5
+ http.json({
6
+ message: 'Welcome to {{name}}!',
7
+ docs: 'https://zerotal.dev/docs',
8
+ });
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { HttpContext, Pipe, NextFn } from '@zerotal/core';
2
+
3
+ export class RequireAuth implements Pipe<HttpContext> {
4
+ async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
5
+ if (!http.user) {
6
+ return Response.json({ message: 'Unauthenticated.' }, { status: 401 });
7
+ }
8
+ return next();
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ import { BaseModel, column, table } from '@zerotal/orm';
2
+
3
+ @table('users')
4
+ export class User extends BaseModel {
5
+ // Models guard every attribute by default. List the columns that may be
6
+ // mass-assigned from user input — note `role` is deliberately excluded so a
7
+ // request body cannot escalate privileges via `User.create(ctx.body())`.
8
+ static override fillable = ['name', 'email', 'password'];
9
+
10
+ static override hidden = ['password'];
11
+
12
+ @column({ type: 'string' }) name!: string;
13
+ @column({ type: 'string' }) email!: string;
14
+ @column({ type: 'string' }) password!: string;
15
+ @column({ type: 'string' }) role!: string;
16
+ }
@@ -0,0 +1,19 @@
1
+ import { Application } from '@zerotal/core';
2
+ import { DatabaseProvider } from '@zerotal/orm';
3
+ import { SessionProvider } from '@zerotal/session';
4
+ import { AuthProvider } from '@zerotal/auth';
5
+ import { NotificationProvider } from '@zerotal/notifications';
6
+ import { QueueProvider } from '@zerotal/queue';
7
+ import { LogProvider } from '@zerotal/core/logger';
8
+
9
+ export default Application.create({
10
+ providers: [
11
+ DatabaseProvider,
12
+ SessionProvider,
13
+ AuthProvider,
14
+ NotificationProvider,
15
+ QueueProvider,
16
+ LogProvider,
17
+ ],
18
+ })
19
+ .routing({ web: `${import.meta.dir}/../routes/index.ts` });
@@ -0,0 +1,8 @@
1
+ import { env } from '@zerotal/core';
2
+
3
+ export default {
4
+ name: '{{name}}',
5
+ env: env('APP_ENV', 'console'),
6
+ key: env('APP_KEY', ''),
7
+ url: env('APP_URL', 'http://localhost:3000'),
8
+ };
@@ -0,0 +1,6 @@
1
+ import { env } from '@zerotal/core';
2
+ import { DatabaseConfig } from '@zerotal/orm';
3
+
4
+ export default DatabaseConfig({
5
+ url: env('DATABASE_URL', '{{db_url}}'),
6
+ });
@@ -0,0 +1,13 @@
1
+ import { env } from '@zerotal/core';
2
+ import { NotificationConfig } from '@zerotal/notifications';
3
+
4
+ export default NotificationConfig({
5
+ // The mail channel — delivery is via the configured driver (log/smtp/resend).
6
+ mail: {
7
+ driver: env('MAIL_DRIVER', 'log') as 'log' | 'smtp' | 'resend',
8
+ from: {
9
+ address: env('MAIL_FROM_ADDRESS', 'noreply@{{name}}.local'),
10
+ name: env('MAIL_FROM_NAME', '{{name}}'),
11
+ },
12
+ },
13
+ });
@@ -0,0 +1,6 @@
1
+ import { env } from '@zerotal/core';
2
+
3
+ export default {
4
+ driver: env('QUEUE_DRIVER', 'sync'),
5
+ connection: env('DATABASE_URL', '{{db_url}}'),
6
+ };
@@ -0,0 +1,10 @@
1
+ import { env } from '@zerotal/core';
2
+ import { SessionConfig } from '@zerotal/session';
3
+
4
+ export default SessionConfig({
5
+ driver: env('SESSION_DRIVER', 'cookie') as 'cookie' | 'redis',
6
+ cookie: env('SESSION_NAME', 'session'),
7
+ secret: env('SESSION_SECRET') ?? env('APP_KEY', ''),
8
+ lifetime: env('SESSION_TTL', 7200),
9
+ secure: env('APP_ENV') === 'production',
10
+ });
@@ -0,0 +1,18 @@
1
+ import { Migration, Schema } from '@zerotal/orm';
2
+
3
+ export default class CreateUsersTable extends Migration {
4
+ async up(): Promise<void> {
5
+ await Schema.create('users', (t) => {
6
+ t.increments('id');
7
+ t.string('name');
8
+ t.string('email').unique();
9
+ t.string('password');
10
+ t.string('role').default('user');
11
+ t.timestamps();
12
+ });
13
+ }
14
+
15
+ async down(): Promise<void> {
16
+ await Schema.drop('users');
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "zt": "bun zt.ts",
8
+ "dev": "bun zt.ts serve --dev",
9
+ "start": "bun zt.ts serve",
10
+ "test": "bun zt.ts test"
11
+ },
12
+ "dependencies": {
13
+ "@zerotal/notifications": "{{zerotal_version}}",
14
+ "zerotal": "{{zerotal_version}}"
15
+ }
16
+ }
@@ -0,0 +1,13 @@
1
+ import { Router } from '@zerotal/core';
2
+ import { WelcomeController } from '../app/controllers/WelcomeController.ts';
3
+ import { AuthController } from '../app/controllers/AuthController.ts';
4
+ import { RequireAuth } from '../app/middleware/RequireAuth.ts';
5
+
6
+ // Public
7
+ Router.get('/', WelcomeController, 'index');
8
+ Router.post('/register', AuthController, 'register');
9
+ Router.post('/login', AuthController, 'login');
10
+ Router.post('/logout', AuthController, 'logout');
11
+
12
+ // Protected
13
+ Router.get('/me', AuthController, 'me', [RequireAuth]);
@@ -0,0 +1,77 @@
1
+ # Tests
2
+
3
+ ## Running tests
4
+
5
+ ```bash
6
+ bun zt test # run all tests (sets APP_ENV=test, auto-wires the DB)
7
+ bun zt test --watch # watch mode
8
+ bun zt test --bail # stop at the first failure
9
+ ```
10
+
11
+ ## How a test is put together
12
+
13
+ `helpers.ts` exports `createApp()`, which boots this application configured for
14
+ tests — in-memory database, synchronous queue, a known session secret. Every test
15
+ file uses it, so adding a provider to the app means editing one place rather than
16
+ every suite.
17
+
18
+ `auth.test.ts` is a complete worked example. The shape it uses:
19
+
20
+ ```ts
21
+ import { describe, it, beforeAll, afterAll } from 'bun:test';
22
+ import { migrateDatabase, refreshDatabase, assertDatabaseHas } from '@zerotal/testing';
23
+ import type { TestApp } from '@zerotal/testing';
24
+ import '../routes/index.ts';
25
+ import { createApp } from './helpers.ts';
26
+
27
+ let app: TestApp;
28
+
29
+ beforeAll(async () => {
30
+ app = await createApp();
31
+ await migrateDatabase(); // build the schema from database/migrations
32
+ });
33
+
34
+ afterAll(() => app.close());
35
+
36
+ describe('GET /posts', () => {
37
+ refreshDatabase(); // each test runs in a transaction that rolls back
38
+
39
+ it('lists posts', async () => {
40
+ const res = await app.get('/posts');
41
+
42
+ res.assertOk();
43
+ res.assertJsonCount(0, 'data');
44
+ });
45
+ });
46
+ ```
47
+
48
+ Build the schema with `migrateDatabase()` rather than hand-written `CREATE TABLE`
49
+ statements. A second definition of your tables drifts from the real one, and you
50
+ find out when something fails for a reason unrelated to the change you made.
51
+
52
+ ## Useful assertions
53
+
54
+ ```ts
55
+ res.assertOk(); // and assertCreated, assertNotFound, …
56
+ res.assertJsonPath('data.0.title', 'Hi');
57
+ res.assertInvalid('email'); // 422 errors, or errors flashed to the session
58
+ res.assertAuthenticatedAs(user);
59
+ res.assertSessionHas('cart_id');
60
+ await assertDatabaseHas('posts', { slug: 'hello' });
61
+ ```
62
+
63
+ When a test fails on an unexpected `500`, ask for the exception instead of
64
+ guessing from the rendered page:
65
+
66
+ ```ts
67
+ const res = await app.withoutExceptionHandling().get('/checkout');
68
+ console.log(res.exception());
69
+ ```
70
+
71
+ ## Generators
72
+
73
+ ```bash
74
+ bun zt make:test PostTest # tests/feature/PostTest.ts
75
+ bun zt make:test SlugTest --unit # tests/unit/SlugTest.ts
76
+ bun zt make:factory User # database/factories/UserFactory.ts
77
+ ```
@@ -0,0 +1,134 @@
1
+ import { describe, it, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ migrateDatabase,
4
+ refreshDatabase,
5
+ assertDatabaseHas,
6
+ assertDatabaseMissing,
7
+ type TestApp,
8
+ } from '@zerotal/testing';
9
+ import '../routes/index.ts';
10
+ import { createApp } from './helpers.ts';
11
+
12
+ let app: TestApp;
13
+
14
+ beforeAll(async () => {
15
+ app = await createApp();
16
+ // Build the schema from the same migrations that ship, rather than from a
17
+ // second copy of the tables written by hand — the two always drift.
18
+ await migrateDatabase();
19
+ });
20
+
21
+ afterAll(() => app.close());
22
+
23
+ describe('registration', () => {
24
+ // Every test below runs in a transaction that rolls back, so they each start
25
+ // from the same empty database and can be read in any order.
26
+ refreshDatabase();
27
+
28
+ it('creates a user and signs them in', async () => {
29
+ const res = await app.asJson().post('/register', {
30
+ name: 'Ada Lovelace',
31
+ email: 'ada@example.com',
32
+ password: 'correct-horse',
33
+ });
34
+
35
+ res.assertCreated();
36
+ res.assertJsonPath('data.email', 'ada@example.com');
37
+ await assertDatabaseHas('users', { email: 'ada@example.com' });
38
+ });
39
+
40
+ it('never stores the password as given', async () => {
41
+ await app.asJson().post('/register', {
42
+ name: 'Ada',
43
+ email: 'ada@example.com',
44
+ password: 'correct-horse',
45
+ });
46
+
47
+ await assertDatabaseMissing('users', { password: 'correct-horse' });
48
+ });
49
+
50
+ it('rejects an incomplete payload', async () => {
51
+ const res = await app.asJson().post('/register', { email: 'ada@example.com' });
52
+
53
+ res.assertUnprocessable();
54
+ await assertDatabaseMissing('users', { email: 'ada@example.com' });
55
+ });
56
+
57
+ it('rejects a duplicate email', async () => {
58
+ const payload = { name: 'Ada', email: 'ada@example.com', password: 'secret' };
59
+ await app.asJson().post('/register', payload);
60
+
61
+ const res = await app.asJson().post('/register', payload);
62
+
63
+ res.assertUnprocessable();
64
+ });
65
+ });
66
+
67
+ describe('login', () => {
68
+ refreshDatabase();
69
+
70
+ it('signs a registered user in', async () => {
71
+ await app.asJson().post('/register', {
72
+ name: 'Ada',
73
+ email: 'ada@example.com',
74
+ password: 'correct-horse',
75
+ });
76
+
77
+ const res = await app.asJson().post('/login', {
78
+ email: 'ada@example.com',
79
+ password: 'correct-horse',
80
+ });
81
+
82
+ res.assertOk();
83
+ res.assertAuthenticated();
84
+ });
85
+
86
+ it('refuses a wrong password without saying which field was wrong', async () => {
87
+ await app.asJson().post('/register', {
88
+ name: 'Ada',
89
+ email: 'ada@example.com',
90
+ password: 'correct-horse',
91
+ });
92
+
93
+ const res = await app.asJson().post('/login', {
94
+ email: 'ada@example.com',
95
+ password: 'wrong',
96
+ });
97
+
98
+ res.assertUnauthorized();
99
+ res.assertGuest();
100
+ });
101
+
102
+ it('refuses an unknown email', async () => {
103
+ const res = await app.asJson().post('/login', {
104
+ email: 'nobody@example.com',
105
+ password: 'secret',
106
+ });
107
+
108
+ res.assertUnauthorized();
109
+ });
110
+ });
111
+
112
+ describe('GET /me', () => {
113
+ refreshDatabase();
114
+
115
+ it('turns a guest away', async () => {
116
+ const res = await app.actingAsGuest().asJson().get('/me');
117
+
118
+ res.assertUnauthorized();
119
+ });
120
+
121
+ it('returns the signed-in user', async () => {
122
+ const created = await app.asJson().post('/register', {
123
+ name: 'Ada Lovelace',
124
+ email: 'ada@example.com',
125
+ password: 'correct-horse',
126
+ });
127
+ const id = created.json<{ data: { id: number } }>().data.id;
128
+
129
+ const res = await app.actingAs({ id }).asJson().get('/me');
130
+
131
+ res.assertOk();
132
+ res.assertJsonPath('data.name', 'Ada Lovelace');
133
+ });
134
+ });
@@ -0,0 +1,36 @@
1
+ import { Application } from '@zerotal/core';
2
+ import { DatabaseProvider } from '@zerotal/orm';
3
+ import { SessionProvider, AuthSessionMiddleware, CookieDriver } from '@zerotal/session';
4
+ import { AuthProvider } from '@zerotal/auth';
5
+ import { NotificationProvider } from '@zerotal/notifications';
6
+ import { QueueProvider } from '@zerotal/queue';
7
+ import { createTestApp, type TestApp } from '@zerotal/testing';
8
+ import { User } from '../app/models/User.ts';
9
+
10
+ export const TEST_SESSION_SECRET = 'test-secret';
11
+
12
+ export function createApp(setup?: () => void): Promise<TestApp> {
13
+ const driver = new CookieDriver(TEST_SESSION_SECRET, 'session');
14
+
15
+ return createTestApp(
16
+ () =>
17
+ Application.create({ env: 'test' })
18
+ .register([DatabaseProvider, SessionProvider, AuthProvider, NotificationProvider, QueueProvider])
19
+ .use([
20
+ class extends AuthSessionMiddleware {
21
+ constructor() {
22
+ super(driver, (id) => User.find(id) as Promise<{ id: number } | null>);
23
+ }
24
+ },
25
+ ])
26
+ .useConfig({
27
+ database: { url: ':memory:' },
28
+ session: { driver: 'cookie', secret: TEST_SESSION_SECRET, cookie: 'session', ttl: 7200 },
29
+ mail: { driver: 'log', from: { address: 'no-reply@test', name: 'Test' },
30
+ smtp: { host: 'localhost', port: 1025, secure: false, username: '', password: '' },
31
+ resend: { apiKey: '' }, log: { channel: 'console' } },
32
+ queue: { driver: 'sync', connection: ':memory:' },
33
+ }),
34
+ setup,
35
+ );
36
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "exactOptionalPropertyTypes": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "emitDecoratorMetadata": false,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["**/*.ts"],
13
+ "exclude": ["node_modules", ".zerotal"]
14
+ }
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bun
2
+ // zt.ts — Zerotal CLI entry point.
3
+ // ─────────────────────────────────────────────────────────────────
4
+ // Managed by the framework. All orchestration lives in @zerotal/core's
5
+ // startZerotal(); this file just hands it the app's bootstrap module.
6
+ //
7
+ // Run commands with: bun zt <command>
8
+ // bun zt serve [--port=3000] [--dev] Start the HTTP server
9
+ // bun zt worker Start the background worker
10
+ // bun zt migrate Run database migrations
11
+ // bun zt test Run tests with the app booted
12
+ // bun zt list Show all available commands
13
+ // ─────────────────────────────────────────────────────────────────
14
+ import { startZerotal } from "zerotal";
15
+
16
+ await startZerotal(() => import("./bootstrap/app"));
@@ -0,0 +1,2 @@
1
+ APP_ENV=development
2
+ APP_KEY={{app_key}}
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .zerotal/
3
+ *.sqlite
4
+ .env
5
+ public/app.js
6
+ public/app.css
@@ -0,0 +1,52 @@
1
+ import { Layout, Link } from "@zerotal/flow";
2
+ import type { HtmlNode } from "@zerotal/flow";
3
+ import { asset } from "zerotal/assets";
4
+
5
+ const NAV = [
6
+ { href: "/", label: "Home" },
7
+ { href: "/about", label: "About" },
8
+ { href: "/contact", label: "Contact" },
9
+ ];
10
+
11
+ export class AppLayout extends Layout {
12
+ // Built by `bun zt serve` from resources/css/app.css (Tailwind) → public/app.css.
13
+ // A getter (not a static field) so asset() re-runs per request and picks up
14
+ // the latest ?v= cache-busting token after a dev rebuild.
15
+ static override get head(): string {
16
+ return `
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <link rel="stylesheet" href="${asset("/app.css")}">
19
+ `;
20
+ }
21
+
22
+ override render(slot: HtmlNode) {
23
+ // Flow adds `data-current` to the <Link> matching the current URL — style the active item with it.
24
+ const navLink =
25
+ "rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-900 " +
26
+ "data-[current]:bg-indigo-50 data-[current]:text-indigo-700";
27
+
28
+ return (
29
+ <div class="min-h-screen bg-linear-to-b from-white via-white to-indigo-50 text-gray-900 antialiased">
30
+ <header class="sticky top-0 z-10 border-b border-gray-200/70 bg-white/80 backdrop-blur">
31
+ <nav class="mx-auto flex max-w-5xl items-center justify-between px-6 py-3">
32
+ {/* Brand opts out of the active style (current={false}) so it isn't highlighted on Home. */}
33
+ <Link href="/" current={false} class="flex items-center gap-2 font-bold tracking-tight">
34
+ <img src={asset("/zt.svg")} alt="" class="h-7 w-7" />
35
+ <span>Zerotal</span>
36
+ </Link>
37
+
38
+ <div class="flex items-center gap-1">
39
+ {NAV.map((item) => (
40
+ <Link href={item.href} hover class={navLink}>
41
+ {item.label}
42
+ </Link>
43
+ ))}
44
+ </div>
45
+ </nav>
46
+ </header>
47
+
48
+ <main class="mx-auto max-w-3xl px-6 py-14">{slot}</main>
49
+ </div>
50
+ );
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ import { Component } from "@zerotal/flow";
2
+ import type { HtmlNode } from "@zerotal/flow";
3
+ import { AppLayout } from "../layouts/app";
4
+
5
+ export class AboutPage extends Component {
6
+ static layout = AppLayout;
7
+ static title = "About";
8
+
9
+ async render(): Promise<HtmlNode> {
10
+ return (
11
+ <section class="py-4">
12
+ <h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">About</h1>
13
+ <div class="mt-6 space-y-4 text-lg leading-relaxed text-gray-600">
14
+ <p>
15
+ This is an example app built with{" "}
16
+ <span class="font-medium text-gray-900">Zerotal</span> and its server-driven view
17
+ layer, <span class="font-medium text-gray-900">Flow</span>.
18
+ </p>
19
+ <p>
20
+ Each file under{" "}
21
+ <code class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm text-indigo-600">
22
+ app/flow/pages
23
+ </code>{" "}
24
+ becomes a route — this page lives in{" "}
25
+ <code class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm text-indigo-600">
26
+ about.tsx
27
+ </code>{" "}
28
+ and is served at <span class="font-medium text-gray-900">/about</span>. Navigation
29
+ between pages happens over a WebSocket, so it feels instant — no full reloads.
30
+ </p>
31
+ </div>
32
+ </section>
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,47 @@
1
+ import { Component } from "@zerotal/flow";
2
+ import type { HtmlNode } from "@zerotal/flow";
3
+ import { AppLayout } from "../layouts/app";
4
+
5
+ export class ContactPage extends Component {
6
+ static layout = AppLayout;
7
+ static title = "Contact";
8
+
9
+ async render(): Promise<HtmlNode> {
10
+ return (
11
+ <section class="py-4">
12
+ <h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">Contact</h1>
13
+ <p class="mt-4 text-lg leading-relaxed text-gray-600">
14
+ Have a question or feedback? We'd love to hear from you.
15
+ </p>
16
+
17
+ <div class="mt-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
18
+ <dl class="space-y-4 text-sm">
19
+ <div class="flex items-center justify-between gap-4">
20
+ <dt class="font-medium text-gray-500">Email</dt>
21
+ <dd>
22
+ <a href="mailto:hello@example.com" class="font-medium text-indigo-600 hover:text-indigo-700">
23
+ hello@example.com
24
+ </a>
25
+ </dd>
26
+ </div>
27
+ <div class="flex items-center justify-between gap-4">
28
+ <dt class="font-medium text-gray-500">GitHub</dt>
29
+ <dd>
30
+ <a href="https://github.com" class="font-medium text-indigo-600 hover:text-indigo-700">
31
+ github.com/your-org
32
+ </a>
33
+ </dd>
34
+ </div>
35
+ </dl>
36
+
37
+ <a
38
+ href="mailto:hello@example.com"
39
+ class="mt-6 inline-block w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700"
40
+ >
41
+ Email us
42
+ </a>
43
+ </div>
44
+ </section>
45
+ );
46
+ }
47
+ }