create-zerotal 1.10.0 → 1.11.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 CHANGED
@@ -8,6 +8,60 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - **The React scaffold ships a page-render test.** Every generated page is built once,
16
+ to prove it can be. The rest of a starter suite asserts values and status codes, and
17
+ a page can throw on its first paint while all of them pass — which is how a blank
18
+ page reaches production with a green suite. New projects inherited that gap; now
19
+ they inherit the check instead. One line per page.
20
+
21
+ ### Fixed
22
+
23
+ - **`config/session.ts` is scaffolded environment-aware**, so the first production
24
+ deploy no longer fails. The production config validator refuses to boot without
25
+ `session.secure`, which is right — but the scaffold shipped `secret`, `cookie` and
26
+ `lifetime` only, so every app built from it hit that refusal on its first deploy,
27
+ after uploading, installing and migrating, at the moment it tried to serve. It also
28
+ cannot simply be `true`: development runs on `http://localhost`, where a secure
29
+ cookie is never sent, and the symptom there is a login that appears to succeed and
30
+ bounces back to the sign-in page with nothing in any log. Now
31
+ `secure: env("APP_ENV", "development") === "production"`, with `httpOnly` and
32
+ `sameSite: "Lax"` — `Lax` because invitation and password-reset links arrive from a
33
+ mail client, and `Strict` withholds the cookie on exactly that navigation.
34
+
35
+ - **`--success` meets WCAG AA.** The light-palette token was `oklch(0.6 0.14 158)`,
36
+ which measures 3.1:1 as status text against the 10% success tint it is drawn on —
37
+ under the 4.5:1 AA needs. Measuring it against the surface *under* the tint is what
38
+ made it look like it passed. Now `oklch(0.504 0.11 158)`.
39
+
40
+ ### Fixed
41
+
42
+ - **`.env.example` no longer ships the key the project actually runs with.** Both
43
+ files got the same rendered content, so every scaffolded project committed a live,
44
+ working `APP_KEY` — `.gitignore` covers `.env` and not `.env.example`. And
45
+ `cp .env.example .env` is the first line of every deployment guide there is, so the
46
+ published key went on to sign production sessions. One key across a laptop and a
47
+ server is one compromise across both, and no strength check can catch it: as a
48
+ string the value is perfectly strong. The example now carries a placeholder naming
49
+ `key:generate`; `.env` still gets a fresh key, so a new app boots immediately.
50
+
51
+ - **`.gitignore` covers the SQLite sidecars.** It had `*.sqlite`, which does not match
52
+ `db.sqlite-wal` or `db.sqlite-shm`. WAL mode is on by default so both exist in every
53
+ project, and the write-ahead log holds pages not yet checkpointed into the main
54
+ file — real rows. An app found both in its first commit on a public host, and was
55
+ saved only by the WAL happening to be empty at that moment. Now `*.sqlite*`.
56
+
57
+ ### Changed
58
+
59
+ - **`bun-plugin-tailwind` is pinned, and it and `tailwindcss` moved to
60
+ `dependencies`.** Pinned because `latest` on a build-critical package whose peer
61
+ declaration can take down a deploy is a combination worth removing. Moved because a
62
+ deploy that installs with `--production` and *then* builds on the server has no
63
+ devDependencies — so no Tailwind, and a page with no CSS.
64
+
11
65
  ## [1.7.2] — 2026-08-18
12
66
 
13
67
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zerotal",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Create a new Zerotal application",
5
5
  "license": "MIT",
6
6
  "maturity": "stable",
package/src/scaffold.ts CHANGED
@@ -13,7 +13,7 @@ export type Template = 'minimal' | 'api' | 'admin' | 'flow' | 'react' | 'vue';
13
13
  // "^1.1.0" found for specifier "zerotal"` — the first thing anyone trying the
14
14
  // framework saw. `scaffold.test.ts` now asserts the two agree, so CI fails rather
15
15
  // than the user's install.
16
- export const ZT_VERSION = "^1.10.0";
16
+ export const ZT_VERSION = "^1.11.0";
17
17
 
18
18
  export interface ScaffoldOptions {
19
19
  name: string;
@@ -28,6 +28,18 @@ function randomBase64(bytes: number): string {
28
28
  return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('base64');
29
29
  }
30
30
 
31
+ /**
32
+ * What the secrets read as in the committed `.env.example`.
33
+ *
34
+ * A value nobody could mistake for a working key, and one that names the command
35
+ * that produces a real one. `.env` still gets a freshly generated key, so a new
36
+ * app boots immediately — the example is the copy that goes to a git host.
37
+ */
38
+ const PLACEHOLDER_SECRETS: Record<string, string> = {
39
+ '{{app_key}}': 'run-`bun zt.ts key:generate`-to-fill-this',
40
+ '{{session_secret}}': 'run-`bun zt.ts key:generate`-to-fill-this',
41
+ };
42
+
31
43
  function tokens(opts: ScaffoldOptions): Record<string, string> {
32
44
  const dbUrl: Record<Database, string> = {
33
45
  sqlite: './database/db.sqlite',
@@ -93,12 +105,19 @@ export async function scaffold(opts: ScaffoldOptions): Promise<void> {
93
105
  const content = applyTokens(raw, map);
94
106
  await Bun.write(dest, content);
95
107
 
96
- // Also write a ready-to-run `.env` next to `.env.example`. The example
97
- // already carries a freshly-generated APP_KEY/SESSION_SECRET, so a fresh
98
- // app boots immediately without a manual `cp .env.example .env` +
99
- // `key:generate` step (the old flow left APP_KEY empty on first boot).
108
+ // `.env` gets the real secrets; `.env.example` gets placeholders.
109
+ //
110
+ // Both used to get the same rendered content, which put a live, working
111
+ // APP_KEY into the one file of the pair that `.gitignore` does *not* cover.
112
+ // Every scaffolded project therefore committed its own session-signing key,
113
+ // and `cp .env.example .env` — the first line of every deployment guide there
114
+ // is — carried that published key into production. One key across a laptop
115
+ // and a server is one compromise across both, and a strength check cannot
116
+ // catch it: as a string the value is perfectly strong. The problem is that
117
+ // this particular value was distributed.
100
118
  if (dest.endsWith('.env.example')) {
101
119
  await Bun.write(dest.replace(/\.env\.example$/, '.env'), content);
120
+ await Bun.write(dest, applyTokens(raw, { ...map, ...PLACEHOLDER_SECRETS }));
102
121
  }
103
122
  }
104
123
  }
@@ -1,4 +1,7 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
@@ -7,4 +7,20 @@ export default SessionConfig({
7
7
  secret: env("APP_KEY", ""),
8
8
  cookie: env("SESSION_COOKIE", "zerotal_session"),
9
9
  lifetime: 60 * 24 * 7,
10
+ // `secure` gates the cookie on HTTPS, and the two environments need opposite
11
+ // answers. Production refuses to boot without it — the config validator is right
12
+ // to — but development runs on http://localhost, where a secure cookie is never
13
+ // sent at all, and the symptom there is a login that appears to succeed and then
14
+ // bounces back to the sign-in page with nothing in any log.
15
+ //
16
+ // Scaffolded environment-aware because every app ends up writing this line, and
17
+ // the ones that write it after their first failed production deploy write it at
18
+ // the worst possible moment.
19
+ secure: env("APP_ENV", "development") === "production",
20
+ httpOnly: true,
21
+
22
+ // `Lax`, not `Strict`. Invitation and password-reset links arrive from a mail
23
+ // client, and `Strict` withholds the cookie on exactly that first cross-site
24
+ // navigation — so the link lands the user on a sign-in page instead.
25
+ sameSite: "Lax",
10
26
  });
@@ -1,5 +1,8 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
5
8
  public/assets/
@@ -1,6 +1,9 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
5
8
  public/app.js
6
9
  public/app.css
@@ -6,6 +6,8 @@ export default SessionConfig({
6
6
  // Cookie sessions are signed, so the driver needs the app key.
7
7
  secret: env("APP_KEY", ""),
8
8
  cookie: "{{name}}_session",
9
+ // Seven days suits a demo. Anything holding a credential wants less — an app
10
+ // whose session carries a live mailbox password runs ten hours.
9
11
  lifetime: 60 * 60 * 24 * 7, // 7 days
10
12
  // Tied to the scheme rather than hardcoded: a `secure` cookie is never sent
11
13
  // over plain HTTP, so setting it unconditionally would drop the session in
@@ -15,7 +15,7 @@
15
15
  "@zerotal/flow": "{{zerotal_version}}",
16
16
  "zerotal": "{{zerotal_version}}",
17
17
  "tailwindcss": "^4.0.0",
18
- "bun-plugin-tailwind": "latest"
18
+ "bun-plugin-tailwind": "0.1.2"
19
19
  },
20
20
  "engines": {
21
21
  "bun": ">=1.3.14"
@@ -1,6 +1,9 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
5
8
  public/app.js
6
9
  public/app.css
@@ -14,7 +14,7 @@
14
14
  "@zerotal/devtools": "{{zerotal_version}}",
15
15
  "zerotal": "{{zerotal_version}}",
16
16
  "tailwindcss": "^4.0.0",
17
- "bun-plugin-tailwind": "latest"
17
+ "bun-plugin-tailwind": "0.1.2"
18
18
  },
19
19
  "engines": {
20
20
  "bun": ">=1.3.14"
@@ -1,5 +1,8 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
5
8
  public/assets/
@@ -7,5 +7,23 @@ import { SessionConfig } from "zerotal/session";
7
7
  export default SessionConfig({
8
8
  secret: env("APP_KEY", ""),
9
9
  cookie: "{{name}}_session",
10
+ // Seven days suits a demo. Anything holding a credential wants less — an app
11
+ // whose session carries a live mailbox password runs ten hours.
10
12
  lifetime: 60 * 60 * 24 * 7, // 7 days
13
+ // `secure` gates the cookie on HTTPS, and the two environments need opposite
14
+ // answers. Production refuses to boot without it — the config validator is right
15
+ // to — but development runs on http://localhost, where a secure cookie is never
16
+ // sent at all, and the symptom there is a login that appears to succeed and then
17
+ // bounces back to the sign-in page with nothing in any log.
18
+ //
19
+ // Scaffolded environment-aware because every app ends up writing this line, and
20
+ // the ones that write it after their first failed production deploy write it at
21
+ // the worst possible moment.
22
+ secure: env("APP_ENV", "development") === "production",
23
+ httpOnly: true,
24
+
25
+ // `Lax`, not `Strict`. Invitation and password-reset links arrive from a mail
26
+ // client, and `Strict` withholds the cookie on exactly that first cross-site
27
+ // navigation — so the link lands the user on a sign-in page instead.
28
+ sameSite: "Lax",
11
29
  });
@@ -12,20 +12,20 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@zerotal/devtools": "{{zerotal_version}}",
16
15
  "@inertiajs/react": "^3.0.0",
16
+ "@zerotal/devtools": "{{zerotal_version}}",
17
17
  "@zerotal/inertia": "{{zerotal_version}}",
18
+ "bun-plugin-tailwind": "0.1.2",
18
19
  "react": "^19.0.0",
19
20
  "react-dom": "^19.0.0",
21
+ "tailwindcss": "^4.0.0",
20
22
  "zerotal": "{{zerotal_version}}"
21
23
  },
22
24
  "devDependencies": {
25
+ "@types/bun": "^1.3.14",
23
26
  "@types/react": "^19.0.0",
24
27
  "@types/react-dom": "^19.0.0",
25
- "bun-plugin-tailwind": "latest",
26
- "tailwindcss": "^4.0.0",
27
- "typescript": "^5.8.0",
28
- "@types/bun": "^1.3.14"
28
+ "typescript": "^5.8.0"
29
29
  },
30
30
  "engines": {
31
31
  "bun": ">=1.3.14"
@@ -46,7 +46,10 @@
46
46
  --accent: oklch(0.958 0.018 286);
47
47
  --accent-foreground: oklch(0.36 0.14 286);
48
48
 
49
- --success: oklch(0.6 0.14 158);
49
+ /* 0.504, not 0.6: measured as status *text* against the 10% success tint it is
50
+ drawn on, 0.6 is 3.1:1 — under the 4.5:1 WCAG AA needs. Measuring it against
51
+ the surface *under* the tint is what makes it look like it passes. */
52
+ --success: oklch(0.504 0.11 158);
50
53
  --success-foreground: oklch(0.99 0.01 158);
51
54
 
52
55
  --destructive: oklch(0.577 0.245 27.3);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Every page is built once, to prove it can be.
3
+ *
4
+ * The rest of the suite asserts values and status codes, and a page can throw on
5
+ * its first paint while every one of them passes: the route answers `200`, the
6
+ * Inertia payload is correct, and the failure happens in a browser the test never
7
+ * opened. An app shipped a blank page to production with 614 green tests exactly
8
+ * that way — a layout callback read `page.props`, which the callback is not given.
9
+ *
10
+ * This proves one thing only: the component tree builds without throwing. That is
11
+ * precisely the thing nothing else here checks. It is not a DOM — `useEffect` does
12
+ * not run, and nothing clicks; for that, see `@zerotal/testing/browser`.
13
+ *
14
+ * Add a line when you add a page. The cost is one line and the bug it catches is a
15
+ * white screen your users find first.
16
+ */
17
+ import { describe, test } from 'bun:test';
18
+ import { renderPage } from '@zerotal/inertia/testing';
19
+ import { defineRoutes } from 'zerotal/routes';
20
+ import { ROUTES } from '../types/routes.generated';
21
+
22
+ import About from '../resources/js/pages/about';
23
+ import Contact from '../resources/js/pages/contact';
24
+ import ForgotPassword from '../resources/js/pages/forgot-password';
25
+ import Home from '../resources/js/pages/home';
26
+ import Login from '../resources/js/pages/login';
27
+ import Profile from '../resources/js/pages/profile';
28
+ import Register from '../resources/js/pages/register';
29
+ import ResetPassword from '../resources/js/pages/reset-password';
30
+
31
+ // The same line `resources/js/app.tsx` runs in the browser. `defineRoutes` installs
32
+ // `route()` as a global, and the layout calls it — so without this every page throws
33
+ // `route is not defined`, which is a true statement about a test that has not
34
+ // finished setting up rather than about the page.
35
+ defineRoutes(ROUTES);
36
+
37
+ /**
38
+ * What `Inertia.share()` puts on every page. A component that destructures one of
39
+ * these throws without it — a real failure, but not the one these tests are for.
40
+ * Keep this in step with `resources/js/types.ts`.
41
+ */
42
+ const SHARED = {
43
+ auth: { user: null },
44
+ flash: { success: null, error: null },
45
+ errors: {},
46
+ old: {},
47
+ };
48
+
49
+ const PAGES: [string, unknown, Record<string, unknown>][] = [
50
+ ['home', Home, { title: 'Home', message: 'Welcome' }],
51
+ ['about', About, { title: 'About' }],
52
+ ['contact', Contact, { title: 'Contact' }],
53
+ ['login', Login, { title: 'Sign in' }],
54
+ ['register', Register, { title: 'Create an account' }],
55
+ ['forgot-password', ForgotPassword, { title: 'Forgot password' }],
56
+ ['reset-password', ResetPassword, { title: 'Reset password', token: 'test-token' }],
57
+ ['profile', Profile, { title: 'Profile' }],
58
+ ];
59
+
60
+ describe('pages render', () => {
61
+ for (const [name, Page, props] of PAGES) {
62
+ test(`${name} builds without throwing`, async () => {
63
+ await renderPage(Page, props, { shared: SHARED, url: `/${name}` });
64
+ });
65
+ }
66
+ });
67
+
68
+ /**
69
+ * A signed-in render as well, because a page that only ever renders as a guest has
70
+ * had half of it tested. `profile` reads `auth.user` and is the one that shows it.
71
+ */
72
+ describe('pages render for a signed-in user', () => {
73
+ const user = { id: 1, name: 'Ada', email: 'ada@example.com' };
74
+
75
+ test('profile builds with a user', async () => {
76
+ await renderPage(
77
+ Profile,
78
+ { title: 'Profile' },
79
+ { shared: { ...SHARED, auth: { user } }, url: '/profile' },
80
+ );
81
+ });
82
+ });
@@ -1,5 +1,8 @@
1
1
  node_modules/
2
2
  .zerotal/
3
- *.sqlite
3
+ # `*.sqlite` alone misses `db.sqlite-wal` and `db.sqlite-shm`. WAL mode is on by
4
+ # default, so both exist in every project, and the write-ahead log holds rows that
5
+ # have not been checkpointed into the main file yet — real ones.
6
+ *.sqlite*
4
7
  .env
5
8
  public/assets/
@@ -7,5 +7,23 @@ import { SessionConfig } from "zerotal/session";
7
7
  export default SessionConfig({
8
8
  secret: env("APP_KEY", ""),
9
9
  cookie: "{{name}}_session",
10
+ // Seven days suits a demo. Anything holding a credential wants less — an app
11
+ // whose session carries a live mailbox password runs ten hours.
10
12
  lifetime: 60 * 60 * 24 * 7, // 7 days
13
+ // `secure` gates the cookie on HTTPS, and the two environments need opposite
14
+ // answers. Production refuses to boot without it — the config validator is right
15
+ // to — but development runs on http://localhost, where a secure cookie is never
16
+ // sent at all, and the symptom there is a login that appears to succeed and then
17
+ // bounces back to the sign-in page with nothing in any log.
18
+ //
19
+ // Scaffolded environment-aware because every app ends up writing this line, and
20
+ // the ones that write it after their first failed production deploy write it at
21
+ // the worst possible moment.
22
+ secure: env("APP_ENV", "development") === "production",
23
+ httpOnly: true,
24
+
25
+ // `Lax`, not `Strict`. Invitation and password-reset links arrive from a mail
26
+ // client, and `Strict` withholds the cookie on exactly that first cross-site
27
+ // navigation — so the link lands the user on a sign-in page instead.
28
+ sameSite: "Lax",
11
29
  });
@@ -14,15 +14,15 @@
14
14
  "dependencies": {
15
15
  "@inertiajs/vue3": "^3.0.0",
16
16
  "@zerotal/inertia": "{{zerotal_version}}",
17
+ "bun-plugin-tailwind": "0.1.2",
18
+ "tailwindcss": "^4.0.0",
17
19
  "vue": "^3.5.0",
18
20
  "zerotal": "{{zerotal_version}}"
19
21
  },
20
22
  "devDependencies": {
23
+ "@types/bun": "^1.3.14",
21
24
  "@vue/compiler-sfc": "^3.5.0",
22
- "bun-plugin-tailwind": "latest",
23
- "tailwindcss": "^4.0.0",
24
- "typescript": "^5.8.0",
25
- "@types/bun": "^1.3.14"
25
+ "typescript": "^5.8.0"
26
26
  },
27
27
  "engines": {
28
28
  "bun": ">=1.3.14"
@@ -46,7 +46,10 @@
46
46
  --accent: oklch(0.958 0.018 286);
47
47
  --accent-foreground: oklch(0.36 0.14 286);
48
48
 
49
- --success: oklch(0.6 0.14 158);
49
+ /* 0.504, not 0.6: measured as status *text* against the 10% success tint it is
50
+ drawn on, 0.6 is 3.1:1 — under the 4.5:1 WCAG AA needs. Measuring it against
51
+ the surface *under* the tint is what makes it look like it passes. */
52
+ --success: oklch(0.504 0.11 158);
50
53
  --success-foreground: oklch(0.99 0.01 158);
51
54
 
52
55
  --destructive: oklch(0.577 0.245 27.3);