create-tigra 2.7.2 → 3.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/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +44 -5
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/05-security.md +1 -1
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +15 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/app/globals.css +8 -0
- package/template/client/src/app/layout.tsx +7 -1
- package/template/client/src/app/providers.tsx +5 -4
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +3 -0
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +5 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +27 -44
- package/template/client/src/features/auth/hooks/useAuth.ts +6 -2
- package/template/client/src/features/auth/hooks/useCurrentUser.ts +50 -0
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +42 -0
- package/template/server/.env.example.production +40 -0
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +15 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -76
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +41 -15
- package/template/server/src/config/env.ts +72 -28
- package/template/server/src/config/rate-limit.config.ts +17 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/auth.ts +15 -18
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +10 -4
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +16 -5
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- package/template/server/vitest.config.ts +43 -43
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Includes `.claude/` rules for Claude Code with project-specific conventions, arc
|
|
|
44
44
|
|
|
45
45
|
## Prerequisites
|
|
46
46
|
|
|
47
|
-
- **Node.js**
|
|
47
|
+
- **Node.js** 22.12+
|
|
48
48
|
- **Docker** (for MySQL and Redis)
|
|
49
49
|
|
|
50
50
|
## After Scaffolding
|
|
@@ -72,8 +72,15 @@ npm run dev
|
|
|
72
72
|
|
|
73
73
|
- Server: http://localhost:8000
|
|
74
74
|
- Client: http://localhost:3000
|
|
75
|
-
- phpMyAdmin: http://localhost:8080
|
|
76
|
-
- Redis Commander: http://localhost:8081
|
|
75
|
+
- phpMyAdmin: http://localhost:8080 (optional — see below)
|
|
76
|
+
- Redis Commander: http://localhost:8081 (optional — see below)
|
|
77
|
+
|
|
78
|
+
The admin UIs (phpMyAdmin, Redis Commander) are behind the docker-compose `tools` profile and do **not** start with plain `docker compose up -d`. Start them with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd my-app/server
|
|
82
|
+
docker compose --profile tools up -d
|
|
83
|
+
```
|
|
77
84
|
|
|
78
85
|
## License
|
|
79
86
|
|
package/bin/create-tigra.js
CHANGED
|
@@ -17,6 +17,10 @@ const VERSION = packageJson.version;
|
|
|
17
17
|
|
|
18
18
|
const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
|
|
19
19
|
|
|
20
|
+
// Non-secret placeholder written into the COMMITTED server/.env.example.
|
|
21
|
+
// The real secret replaces it only in the git-ignored server/.env.
|
|
22
|
+
const JWT_SECRET_PLACEHOLDER = 'CHANGE_ME_generate_with_openssl_rand_hex_48';
|
|
23
|
+
|
|
20
24
|
// Files that contain template variables and need replacement
|
|
21
25
|
const FILES_TO_REPLACE = [
|
|
22
26
|
'server/package.json',
|
|
@@ -24,6 +28,7 @@ const FILES_TO_REPLACE = [
|
|
|
24
28
|
'server/docker-compose.yml',
|
|
25
29
|
'client/package.json',
|
|
26
30
|
'client/.env.example',
|
|
31
|
+
'client/.env.example.production',
|
|
27
32
|
'server/postman/collection.json',
|
|
28
33
|
'server/postman/environment.json',
|
|
29
34
|
];
|
|
@@ -123,6 +128,43 @@ function replaceVariables(content, variables) {
|
|
|
123
128
|
return result;
|
|
124
129
|
}
|
|
125
130
|
|
|
131
|
+
// When stdin is closed/non-interactive, `prompts` never settles its promise:
|
|
132
|
+
// the event loop drains and Node exits 0 mid-await — no scaffold, no error.
|
|
133
|
+
// That silently "succeeds" in CI. Track the in-flight prompt and fail loudly.
|
|
134
|
+
let activePromptMessage = null;
|
|
135
|
+
|
|
136
|
+
process.on('exit', (code) => {
|
|
137
|
+
if (activePromptMessage !== null && code === 0) {
|
|
138
|
+
// writeSync: stderr writes via console.error can be lost in an 'exit'
|
|
139
|
+
// handler when stderr is a pipe (async on Windows).
|
|
140
|
+
fs.writeSync(
|
|
141
|
+
2,
|
|
142
|
+
`\n Aborted: the prompt "${activePromptMessage}" was not answered ` +
|
|
143
|
+
`(stdin is closed or non-interactive).\n` +
|
|
144
|
+
` Run in an interactive terminal, or pipe answers via stdin.\n\n`,
|
|
145
|
+
);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function ask(question) {
|
|
151
|
+
activePromptMessage = question.message;
|
|
152
|
+
const response = await prompts(question, {
|
|
153
|
+
onCancel: () => {
|
|
154
|
+
console.error(chalk.red('\n Cancelled.\n'));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
activePromptMessage = null;
|
|
159
|
+
if (response[question.name] === undefined) {
|
|
160
|
+
console.error(
|
|
161
|
+
chalk.red(`\n Aborted: no answer received for "${question.message}".\n`),
|
|
162
|
+
);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
function registerAddCommand(program) {
|
|
127
169
|
program
|
|
128
170
|
.command('add <module>')
|
|
@@ -248,20 +290,12 @@ async function main() {
|
|
|
248
290
|
let projectName = projectNameArg;
|
|
249
291
|
|
|
250
292
|
if (!projectName) {
|
|
251
|
-
const response = await
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
},
|
|
258
|
-
{
|
|
259
|
-
onCancel: () => {
|
|
260
|
-
console.log(chalk.red('\n Cancelled.\n'));
|
|
261
|
-
process.exit(1);
|
|
262
|
-
},
|
|
263
|
-
}
|
|
264
|
-
);
|
|
293
|
+
const response = await ask({
|
|
294
|
+
type: 'text',
|
|
295
|
+
name: 'projectName',
|
|
296
|
+
message: 'What is your project name?',
|
|
297
|
+
validate: validateProjectName,
|
|
298
|
+
});
|
|
265
299
|
projectName = response.projectName;
|
|
266
300
|
}
|
|
267
301
|
|
|
@@ -286,34 +320,30 @@ async function main() {
|
|
|
286
320
|
}
|
|
287
321
|
|
|
288
322
|
// Ask about email verification
|
|
289
|
-
const { enableVerification } = await
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
},
|
|
299
|
-
{
|
|
300
|
-
onCancel: () => {
|
|
301
|
-
console.log(chalk.red('\n Cancelled.\n'));
|
|
302
|
-
process.exit(1);
|
|
303
|
-
},
|
|
304
|
-
}
|
|
305
|
-
);
|
|
323
|
+
const { enableVerification } = await ask({
|
|
324
|
+
type: 'toggle',
|
|
325
|
+
name: 'enableVerification',
|
|
326
|
+
message: 'Enable email verification for new users?',
|
|
327
|
+
initial: false,
|
|
328
|
+
active: 'Yes',
|
|
329
|
+
inactive: 'No',
|
|
330
|
+
hint: 'Users must verify email before accessing the app',
|
|
331
|
+
});
|
|
306
332
|
|
|
307
333
|
// Generate random port offset (1-200) so multiple projects don't conflict
|
|
308
334
|
const portOffset = crypto.randomInt(1, 201);
|
|
309
335
|
|
|
310
|
-
// Derive all variables
|
|
336
|
+
// Derive all variables.
|
|
337
|
+
// SECURITY: everything in this map is written into COMMITTED files
|
|
338
|
+
// (.env.example, docker-compose.yml, ...) — never put a real secret here.
|
|
339
|
+
// JWT_SECRET gets an instructional placeholder; the real secret is
|
|
340
|
+
// generated below and written ONLY to the git-ignored .env.
|
|
311
341
|
const variables = {
|
|
312
342
|
PROJECT_NAME: projectName,
|
|
313
343
|
PROJECT_NAME_SNAKE: toSnakeCase(projectName),
|
|
314
344
|
PROJECT_DISPLAY_NAME: toTitleCase(projectName),
|
|
315
345
|
DATABASE_NAME: `${toSnakeCase(projectName)}_db`,
|
|
316
|
-
JWT_SECRET:
|
|
346
|
+
JWT_SECRET: JWT_SECRET_PLACEHOLDER,
|
|
317
347
|
MYSQL_PORT: String(3306 + portOffset),
|
|
318
348
|
PHPMYADMIN_PORT: String(8080 + portOffset),
|
|
319
349
|
REDIS_PORT: String(6379 + portOffset),
|
|
@@ -337,12 +367,21 @@ async function main() {
|
|
|
337
367
|
}
|
|
338
368
|
}
|
|
339
369
|
|
|
340
|
-
// Generate .env from .env.example (so users don't have to copy manually)
|
|
370
|
+
// Generate .env from .env.example (so users don't have to copy manually).
|
|
371
|
+
// The real JWT secret is injected HERE and only here — .env is
|
|
372
|
+
// git-ignored, while .env.example is committed and must keep the
|
|
373
|
+
// CHANGE_ME placeholder.
|
|
374
|
+
const jwtSecret = crypto.randomBytes(48).toString('hex');
|
|
341
375
|
for (const envExample of ['server/.env.example', 'client/.env.example']) {
|
|
342
376
|
const examplePath = path.join(targetDir, envExample);
|
|
343
377
|
const envPath = path.join(targetDir, envExample.replace('.env.example', '.env'));
|
|
344
378
|
if (await fs.pathExists(examplePath)) {
|
|
345
|
-
await fs.
|
|
379
|
+
const content = await fs.readFile(examplePath, 'utf-8');
|
|
380
|
+
await fs.writeFile(
|
|
381
|
+
envPath,
|
|
382
|
+
content.replaceAll(JWT_SECRET_PLACEHOLDER, jwtSecret),
|
|
383
|
+
'utf-8',
|
|
384
|
+
);
|
|
346
385
|
}
|
|
347
386
|
}
|
|
348
387
|
|
|
@@ -423,8 +462,9 @@ async function main() {
|
|
|
423
462
|
console.log();
|
|
424
463
|
console.log(dim(' App ') + cyan('http://localhost:3000'));
|
|
425
464
|
console.log(dim(' API ') + cyan('http://localhost:8000'));
|
|
426
|
-
console.log(dim('
|
|
427
|
-
console.log(dim('
|
|
465
|
+
console.log(dim(' DB/Redis UIs ') + 'optional — start with ' + cyan('npm run docker:tools'));
|
|
466
|
+
console.log(dim(' phpMyAdmin ') + cyan(`http://localhost:${variables.PHPMYADMIN_PORT}`));
|
|
467
|
+
console.log(dim(' Redis CMD ') + cyan(`http://localhost:${variables.REDIS_COMMANDER_PORT}`));
|
|
428
468
|
console.log();
|
|
429
469
|
console.log(line);
|
|
430
470
|
console.log();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-tigra",
|
|
3
|
-
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"template"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
16
|
+
"node": ">=22.12.0"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"create",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"chalk": "^5.4.1",
|
|
49
|
-
"commander": "^
|
|
49
|
+
"commander": "^15.0.0",
|
|
50
50
|
"fs-extra": "^11.3.0",
|
|
51
|
-
"ora": "^
|
|
51
|
+
"ora": "^9.0.0",
|
|
52
52
|
"prompts": "^2.4.2",
|
|
53
|
-
"ts-morph": "^
|
|
53
|
+
"ts-morph": "^28.0.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -134,12 +134,18 @@ Include: node_modules, dist, .env, .env.local, .env*.local, *.log, .prisma
|
|
|
134
134
|
#### `docker-compose.yml`
|
|
135
135
|
Create a Docker Compose file with these services:
|
|
136
136
|
1. **mysql** - MySQL 8.0, port 3306, database name = `$ARGUMENTS`, root password = `rootpassword`, volume for data persistence, healthcheck
|
|
137
|
-
2. **phpmyadmin** - Latest, port 8080, linked to mysql
|
|
137
|
+
2. **phpmyadmin** - Latest, port 8080, linked to mysql, behind the `tools` profile
|
|
138
138
|
3. **redis** - Redis 7 Alpine, port 6379, volume for data persistence, healthcheck
|
|
139
|
-
4. **redis-commander** - Redis Commander UI, port 8081, linked to redis
|
|
139
|
+
4. **redis-commander** - Redis Commander UI, port 8081, linked to redis, behind the `tools` profile
|
|
140
140
|
|
|
141
141
|
Use a named network for all services. Add restart policies.
|
|
142
142
|
|
|
143
|
+
**Security requirements (dev attack surface):**
|
|
144
|
+
- Bind ALL published ports to localhost (`127.0.0.1:<port>:<container-port>`), never `0.0.0.0` — these are dev credentials (root password, unpassworded Redis).
|
|
145
|
+
- Put the admin UIs (**phpmyadmin**, **redis-commander**) behind `profiles: ["tools"]` so they do NOT start by default:
|
|
146
|
+
- `docker compose up -d` → MySQL + Redis only
|
|
147
|
+
- `docker compose --profile tools up -d` → also starts phpMyAdmin + Redis Commander
|
|
148
|
+
|
|
143
149
|
---
|
|
144
150
|
|
|
145
151
|
### Step 2: Source Code Structure
|
|
@@ -133,3 +133,15 @@ export const CURRENCIES = { USD: 'USD', EUR: 'EUR' } as const;
|
|
|
133
133
|
|
|
134
134
|
Protected paths: `/dashboard`, `/profile`, `/admin` — redirect to `/login` if no token.
|
|
135
135
|
Auth paths: `/login`, `/register` — redirect to `/dashboard` if already authenticated.
|
|
136
|
+
|
|
137
|
+
When creating a new page, always add it to `protectedPaths` and `config.matcher` in `middleware.ts` if it requires authentication. If you are unsure whether a page should be protected, **ask the user** — do not guess.
|
|
138
|
+
|
|
139
|
+
### Deleting a Route
|
|
140
|
+
|
|
141
|
+
When removing a page/route, you MUST update **all three** locations:
|
|
142
|
+
|
|
143
|
+
1. **Delete the page file**: `app/<route>/page.tsx` (and the folder if empty)
|
|
144
|
+
2. **Remove from `routes.ts`**: Delete the entry in `lib/constants/routes.ts`
|
|
145
|
+
3. **Remove from `middleware.ts`**: Delete from `protectedPaths` array AND `config.matcher` array
|
|
146
|
+
|
|
147
|
+
Missing any of these leaves dead references or broken middleware matches.
|
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
Defaults in `app/providers.tsx`:
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
|
-
staleTime:
|
|
29
|
-
gcTime:
|
|
30
|
-
refetchOnWindowFocus:
|
|
28
|
+
staleTime: 30 * 1000 // 30s — short enough that back-navigation refetches
|
|
29
|
+
gcTime: 5 * 60 * 1000 // 5 min
|
|
30
|
+
refetchOnWindowFocus: true // catch cross-tab edits
|
|
31
|
+
refetchOnMount: true // always refetch stale data on mount
|
|
31
32
|
retry: 1
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
**Why these values matter**: With a long `staleTime` (e.g. 5 min) and `refetchOnWindowFocus: false`, navigating back to a list page after editing a record on another page will show stale data until the user hard-refreshes. Keep `staleTime` short and let invalidation + remount refetch do their job.
|
|
36
|
+
|
|
34
37
|
### Query Key Factory Pattern
|
|
35
38
|
|
|
36
39
|
```typescript
|
|
@@ -45,9 +48,35 @@ export const itemKeys = {
|
|
|
45
48
|
|
|
46
49
|
### Mutations
|
|
47
50
|
|
|
48
|
-
On success:
|
|
51
|
+
On success:
|
|
52
|
+
1. **`queryClient.invalidateQueries({ queryKey: ... })`** — refreshes any client-fetched data (React Query).
|
|
53
|
+
2. **`router.refresh()`** — refreshes any Server-Component-rendered data on the next route the user navigates to. Without this, the Next.js Router Cache will serve stale RSC payloads on back-navigation, and your edit will not appear until a hard refresh.
|
|
54
|
+
3. Show a toast.
|
|
55
|
+
4. Navigate if needed.
|
|
56
|
+
|
|
49
57
|
On error: `toast.error(getErrorMessage(error))`.
|
|
50
58
|
|
|
59
|
+
**Always call both `invalidateQueries` AND `router.refresh()`** unless you are 100% certain no Server Component on any reachable route reads the mutated data. The two caches are independent — invalidating one does not touch the other.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const queryClient = useQueryClient();
|
|
64
|
+
|
|
65
|
+
const mutation = useMutation({
|
|
66
|
+
mutationFn: (data) => itemService.updateItem(id, data),
|
|
67
|
+
onSuccess: () => {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: itemKeys.all });
|
|
69
|
+
router.refresh();
|
|
70
|
+
toast.success('Item updated');
|
|
71
|
+
},
|
|
72
|
+
onError: (error) => toast.error(getErrorMessage(error)),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Next.js Router Cache
|
|
77
|
+
|
|
78
|
+
`next.config.ts` sets `experimental.staleTimes: { dynamic: 0, static: 30 }` to minimize client-side Router Cache reuse. `static: 30` is the **Next 16.2+ minimum** — the config schema rejects values below 30, and an invalid value is silently ignored (re-enabling the default Router Cache). `dynamic: 0` is what protects data pages. **Never raise these values above these minimums** — doing so reintroduces the back-navigation stale-data bug across every page that uses Server Components for data fetching.
|
|
79
|
+
|
|
51
80
|
---
|
|
52
81
|
|
|
53
82
|
## Redux
|
|
@@ -61,7 +90,17 @@ State shape:
|
|
|
61
90
|
{ user: IUser | null; isAuthenticated: boolean; isInitializing: boolean; isLoggingOut: boolean }
|
|
62
91
|
```
|
|
63
92
|
|
|
64
|
-
**Not persisted to localStorage** — auth state is hydrated
|
|
93
|
+
**Not persisted to localStorage** — auth state is hydrated by `AuthInitializer`, which calls the `useCurrentUser()` hook. `useCurrentUser()` is a React Query wrapper around `authService.getMe()` that syncs the result into Redux via a side effect. Tokens are stored in httpOnly cookies (not accessible from JS).
|
|
94
|
+
|
|
95
|
+
**Refreshing the current user**: any mutation that changes the logged-in user's own data (profile update, role change, avatar upload, email verification, subscription change, etc.) MUST invalidate the auth query so Redux picks up the new values:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { authKeys } from '@/features/auth/hooks/useCurrentUser';
|
|
99
|
+
|
|
100
|
+
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Without this, Redux will hold the stale snapshot from initial page load until the next window-focus refetch (30s staleTime), or until logout/hard refresh. Never write directly to the auth slice from outside the auth feature — always go through invalidation.
|
|
65
104
|
|
|
66
105
|
---
|
|
67
106
|
|
|
@@ -339,6 +339,28 @@ Link: transition-colors duration-150 active:opacity-70 md:hover:text-primary
|
|
|
339
339
|
- **Sticky action bars**: Form submit buttons, checkout CTAs — `sticky bottom-0` on mobile.
|
|
340
340
|
- **No hamburger menus** for ≤5 items. Use bottom tab bar instead.
|
|
341
341
|
|
|
342
|
+
### BottomNav Overlap — Fixed Bottom Elements
|
|
343
|
+
|
|
344
|
+
When a page has its own `fixed bottom-0` element (e.g., sticky order bar, floating CTA), it **will be hidden behind BottomNav** on mobile. The BottomNav is `fixed bottom-0 z-50` with a height of `4rem` (64px).
|
|
345
|
+
|
|
346
|
+
**Define the CSS variable** in the BottomNav component:
|
|
347
|
+
```css
|
|
348
|
+
:root {
|
|
349
|
+
--bottom-nav-height: 4rem;
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Every fixed bottom element on a page** must offset itself above BottomNav on mobile:
|
|
354
|
+
```
|
|
355
|
+
bottom-[var(--bottom-nav-height)] md:bottom-0
|
|
356
|
+
```
|
|
357
|
+
Or the shorthand equivalent:
|
|
358
|
+
```
|
|
359
|
+
bottom-16 md:bottom-0
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
This is not optional — without it, BottomNav covers the element and users cannot tap it on mobile.
|
|
363
|
+
|
|
342
364
|
---
|
|
343
365
|
|
|
344
366
|
## Images
|
|
@@ -383,3 +405,4 @@ Link: transition-colors duration-150 active:opacity-70 md:hover:text-primary
|
|
|
383
405
|
- Reduce shadow visibility in dark mode (use subtle light borders instead).
|
|
384
406
|
- Consider `brightness-90` on images in dark mode.
|
|
385
407
|
- Add `suppressHydrationWarning` to `<html>` tag.
|
|
408
|
+
- **Hydration guard for conditional theme renders**: `useTheme()` returns `undefined` on the server. Any component that renders *different JSX* based on `theme` (e.g. showing a Sun icon OR a Moon icon — not both) will throw a hydration mismatch. Either render both icons and style the active one (see `components/common/ThemeToggle.tsx`), or add a `mounted` guard: `const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []);` and return `null`/a placeholder until `mounted` is true.
|
|
@@ -37,7 +37,7 @@ X-XSS-Protection: 1; mode=block
|
|
|
37
37
|
default-src 'self';
|
|
38
38
|
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
|
39
39
|
style-src 'self' 'unsafe-inline';
|
|
40
|
-
img-src 'self' blob: data: https
|
|
40
|
+
img-src 'self' blob: data: https: ${apiOrigin};
|
|
41
41
|
font-src 'self';
|
|
42
42
|
object-src 'none';
|
|
43
43
|
base-uri 'self';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
> **SCOPE**: These rules apply specifically to the **client** directory (Next.js App Router).
|
|
2
|
+
|
|
3
|
+
# Deployment & Docker
|
|
4
|
+
|
|
5
|
+
This project is deployed via **Docker** on **Coolify** (or any Docker-based platform). The `Dockerfile` is the production deployment contract. Every code change must remain compatible with it.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Dockerfile Architecture
|
|
10
|
+
|
|
11
|
+
The client uses a **3-stage multi-stage build** with Next.js `standalone` output:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Stage 1 (dependencies) → Installs all node_modules (cached layer)
|
|
15
|
+
Stage 2 (builder) → Copies deps, builds Next.js with standalone output
|
|
16
|
+
Stage 3 (production) → Alpine + dumb-init, non-root user, copies standalone + static + public
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Entry point**: `node server.js` (generated by Next.js standalone output in `.next/standalone/`)
|
|
20
|
+
**Health check**: `GET /` — returns 200 if the Next.js server is running.
|
|
21
|
+
|
|
22
|
+
### Critical Config Requirement
|
|
23
|
+
|
|
24
|
+
`next.config.ts` **must** have `output: "standalone"`. Without it, the Dockerfile will fail because `.next/standalone/` won't be generated. Never remove this setting.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Build Arguments (NEXT_PUBLIC_* Variables)
|
|
29
|
+
|
|
30
|
+
Next.js **inlines** all `NEXT_PUBLIC_*` values into the JavaScript bundle at build time. They are NOT read from the environment at runtime. This means:
|
|
31
|
+
|
|
32
|
+
- Every `NEXT_PUBLIC_*` variable must be declared as `ARG` + `ENV` in the **builder stage** of the Dockerfile.
|
|
33
|
+
- They must be passed via `--build-arg` during `docker build` (or via Coolify's Build Arguments UI).
|
|
34
|
+
- **If you add a new `NEXT_PUBLIC_*` env var, you MUST add it to the Dockerfile builder stage.**
|
|
35
|
+
|
|
36
|
+
```dockerfile
|
|
37
|
+
# In the builder stage:
|
|
38
|
+
ARG NEXT_PUBLIC_NEW_VAR
|
|
39
|
+
ENV NEXT_PUBLIC_NEW_VAR=$NEXT_PUBLIC_NEW_VAR
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Current build arguments:
|
|
43
|
+
- `NEXT_PUBLIC_API_BASE_URL` — Backend API URL (e.g., `https://api.yourdomain.com/api/v1`)
|
|
44
|
+
- `NEXT_PUBLIC_APP_NAME` — App display name
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## When to Update the Dockerfile
|
|
49
|
+
|
|
50
|
+
| You did this... | Update Dockerfile? | What to change |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Added a new npm dependency | No | Automatic — installed during build |
|
|
53
|
+
| Added a native/system dependency (e.g., `sharp` for image optimization) | **Yes** | Add `apk add` in the production stage |
|
|
54
|
+
| Added a new `NEXT_PUBLIC_*` env var | **Yes** | Add `ARG` + `ENV` in the builder stage |
|
|
55
|
+
| Changed the public directory structure | No | Automatic — `COPY /app/public ./public` |
|
|
56
|
+
| Added server-only env vars (no `NEXT_PUBLIC_` prefix) | No | Injected at runtime via Coolify |
|
|
57
|
+
| Changed the default port | **Yes** | Update `ENV PORT`, `EXPOSE`, and `HEALTHCHECK` |
|
|
58
|
+
| Added custom `next.config.ts` rewrites/redirects | No | Baked into the build automatically |
|
|
59
|
+
| Added API routes (`app/api/`) | No | Included in standalone output |
|
|
60
|
+
| Removed `output: "standalone"` from next.config.ts | **BREAKING** | Entire Dockerfile depends on standalone output |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Critical Rules
|
|
65
|
+
|
|
66
|
+
1. **Never remove `output: "standalone"`** from `next.config.ts`. The entire Docker build depends on it. Without it, the standalone server won't be generated and the Dockerfile will fail.
|
|
67
|
+
|
|
68
|
+
2. **Every `NEXT_PUBLIC_*` var needs a Dockerfile `ARG`.** Next.js inlines these at build time. If you add one to `.env.example` but forget the Dockerfile, the value will be `undefined` in production.
|
|
69
|
+
|
|
70
|
+
3. **Static assets and public files are separate.** The standalone output does NOT include `.next/static/` or `public/`. The Dockerfile copies them explicitly. If you add a new top-level directory that must be available at runtime (unlikely), add a `COPY` line.
|
|
71
|
+
|
|
72
|
+
4. **Non-root user.** The app runs as `nextjs:nodejs` (UID 1001). Next.js standalone doesn't write to disk at runtime, so this is straightforward.
|
|
73
|
+
|
|
74
|
+
5. **No secrets in the image.** Server-only env vars (without `NEXT_PUBLIC_` prefix) are injected at runtime. Never hardcode secrets or use `ENV` for sensitive values in the Dockerfile.
|
|
75
|
+
|
|
76
|
+
6. **Keep `.dockerignore` in sync.** When adding directories that should NOT be in the build context (test fixtures, docs, Storybook), add them to `.dockerignore`. When adding files needed at build time, ensure they're not ignored.
|
|
77
|
+
|
|
78
|
+
7. **`HOSTNAME="0.0.0.0"`** is required. Without it, Next.js standalone only listens on `127.0.0.1` inside the container, making it unreachable from outside.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Coolify-Specific Notes
|
|
83
|
+
|
|
84
|
+
- **Build Arguments**: Set `NEXT_PUBLIC_API_BASE_URL` and `NEXT_PUBLIC_APP_NAME` in Coolify's "Build Arguments" section (not environment variables — those are runtime only).
|
|
85
|
+
- **Environment Variables**: Server-only vars (database URLs, API keys used in Server Components/Route Handlers) go in Coolify's "Environment Variables" section — available at runtime.
|
|
86
|
+
- **Port**: Default `3000`. Override via `PORT` environment variable in Coolify.
|
|
87
|
+
- **No volume mounts needed**: Next.js client apps are stateless — no uploads, no local file writes.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Files That Matter for Deployment
|
|
92
|
+
|
|
93
|
+
| File | Purpose | Must exist? |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `Dockerfile` | Production build instructions | Yes |
|
|
96
|
+
| `.dockerignore` | Excludes files from Docker build context | Yes |
|
|
97
|
+
| `next.config.ts` | Must have `output: "standalone"` | Yes |
|
|
98
|
+
| `package.json` | Dependencies and build script | Yes |
|
|
99
|
+
| `public/` | Static assets (favicon, images) | Yes |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
> **SCOPE**: These rules apply specifically to the **client** directory (Next.js App Router).
|
|
2
|
+
|
|
3
|
+
# Lockfile — Cross-Platform Regeneration
|
|
4
|
+
|
|
5
|
+
`client/package-lock.json` has burned us — and bots — multiple times in projects scaffolded from this template. Read this before touching it.
|
|
6
|
+
|
|
7
|
+
## The trap
|
|
8
|
+
|
|
9
|
+
`client/package-lock.json` is consumed by **three different environments**:
|
|
10
|
+
|
|
11
|
+
| Environment | OS / libc | npm |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Local dev (most contributors) | Windows / macOS | npm 11.x |
|
|
14
|
+
| GitHub Actions CI (`server-ci.yml`, `client-ci.yml`) | `ubuntu-latest` (Debian glibc) | npm 10.x (ships with Node 20) |
|
|
15
|
+
| Coolify / production deploy (`client/Dockerfile`) | `node:20-alpine` (musl) | npm 10.x |
|
|
16
|
+
|
|
17
|
+
`npm ci` is **strict** — it refuses to install if the lockfile is even slightly out of sync with `package.json`, AND it only installs the platform-specific `optionalDependencies` whose top-level `node_modules/<pkg>` entries exist in the lockfile. If you regenerate on Windows with `npm install`, you get a Windows-leaning lockfile that:
|
|
18
|
+
|
|
19
|
+
1. May be missing entries the newer npm 10 (CI) considers required (`Missing: @swc/helpers@0.5.21 from lock file` — common failure mode).
|
|
20
|
+
2. Lacks `*-linux-x64-gnu` / `*-linuxmusl-x64` entries → CI's `next build` fails with `Cannot find module '../lightningcss.linux-x64-gnu.node'` or `No prebuild or local build of @parcel/watcher found.`
|
|
21
|
+
|
|
22
|
+
Affected native packages (Next 16 + Tailwind v4 dep tree): `@parcel/watcher`, `lightningcss`, `@img/sharp`, `@next/swc`, `@swc/core`, `@tailwindcss/oxide`, `@unrs/resolver-binding`, `@rolldown/binding`.
|
|
23
|
+
|
|
24
|
+
## Canonical fix (use this exactly)
|
|
25
|
+
|
|
26
|
+
Whenever you regenerate `client/package-lock.json` — even just because `package.json` changed by one dep — run **all three** steps. They are additive (`--package-lock-only` doesn't touch `node_modules`).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd client
|
|
30
|
+
|
|
31
|
+
# 0. (Optional) Start fresh if the existing lockfile is already broken:
|
|
32
|
+
rm -rf node_modules package-lock.json
|
|
33
|
+
|
|
34
|
+
# 1. Sync the lockfile against package.json (fixes the "Missing: foo from lock file" class).
|
|
35
|
+
npx -y npm@10 install --package-lock-only
|
|
36
|
+
|
|
37
|
+
# 2. Add Linux/glibc native variants (GitHub Actions Ubuntu).
|
|
38
|
+
npx -y npm@10 install --os=linux --cpu=x64 --libc=glibc --package-lock-only
|
|
39
|
+
|
|
40
|
+
# 3. Add Linux/musl native variants (Coolify Alpine deploy).
|
|
41
|
+
npx -y npm@10 install --os=linux --cpu=x64 --libc=musl --package-lock-only
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Required choices
|
|
45
|
+
|
|
46
|
+
- **`npx -y npm@10` explicitly.** Local default npm 11.x resolves a tree npm 10 strict-checks reject. Match CI's npm version or you'll push a lockfile that fails immediately.
|
|
47
|
+
- **`--package-lock-only`** keeps each command at ~1 sec (no install, no audit).
|
|
48
|
+
- **Three platforms.** Even if you only intend to fix CI, do the musl pass too — Coolify deploy will fail otherwise.
|
|
49
|
+
- **Skip darwin** unless a team member dev's on macOS and reports `npm ci` failing. Not worth the lockfile churn pre-emptively.
|
|
50
|
+
|
|
51
|
+
## Verify before committing
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cd client
|
|
55
|
+
|
|
56
|
+
# A. Strict install passes with CI's npm version (this is the exact check CI runs):
|
|
57
|
+
npx -y npm@10 ci --include=optional --no-audit --no-fund 2>&1 | tail -3
|
|
58
|
+
# Expect: "added N packages in Xs". Any "EUSAGE" / "Missing:" means step 1 didn't take.
|
|
59
|
+
|
|
60
|
+
# B. All native deps have glibc + musl entries:
|
|
61
|
+
for pkg in '@next/swc' '@swc/core' '@parcel/watcher' '@rolldown/binding' \
|
|
62
|
+
'@tailwindcss/oxide' '@unrs/resolver-binding' 'lightningcss'; do
|
|
63
|
+
g=$(grep -cE "node_modules/${pkg}.*-linux-x64-(gnu|glibc)\"" package-lock.json)
|
|
64
|
+
m=$(grep -cE "node_modules/${pkg}-linux-x64-musl\"" package-lock.json)
|
|
65
|
+
echo "$pkg glibc=$g musl=$m"
|
|
66
|
+
done
|
|
67
|
+
# Expect every line: glibc=1 musl=1. (sharp uses different naming — check separately:
|
|
68
|
+
# grep -oE 'node_modules/@img/sharp[-a-z0-9]*' package-lock.json | sort -u)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If either check fails, the lockfile is not ready — do not commit.
|
|
72
|
+
|
|
73
|
+
## Anti-patterns (don't do these — every one has burned us)
|
|
74
|
+
|
|
75
|
+
1. **Plain `npm install` on Windows/macOS.** Uses local npm 11; produces lockfiles CI rejects.
|
|
76
|
+
2. **Whack-a-mole pinning** in `package.json` `optionalDependencies` (e.g., adding only `@parcel/watcher-linux-x64-glibc` and hoping it fixes everything). It doesn't — there are 7+ such native deps and you'll bounce through them one CI failure at a time.
|
|
77
|
+
3. **Disabling `npm ci` in CI** by switching to `npm install` to "fix" the symptom. That makes builds non-reproducible.
|
|
78
|
+
4. **Bypassing the husky pre-commit hook** with `--no-verify` to push a broken lockfile fast.
|
|
79
|
+
5. **Generating inside Docker on a hung Docker daemon** without verifying the daemon is healthy first — the run silently buffers and you wait 10+ minutes. (The non-Docker `npx npm@10 install --package-lock-only` chain above is faster and equally correct.)
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| Choosing colors, styling, typography, spacing, motion, **theme colors**, **font presets** | `04-design-system.md` |
|
|
13
13
|
| Auth tokens, env vars, security headers | `05-security.md` |
|
|
14
14
|
| UX psychology, cognitive load, a11y, performance | `06-ux-checklist.md` |
|
|
15
|
+
| Regenerating `client/package-lock.json`, fixing CI `npm ci` failures, cross-platform native binaries (`@parcel/watcher`, `lightningcss`, `@img/sharp`, etc.) | `08-lockfile-cross-platform.md` |
|
|
15
16
|
|
|
16
17
|
---
|
|
17
18
|
|
|
@@ -51,7 +51,7 @@ When the user asks to create, scaffold, or start a new server or client project,
|
|
|
51
51
|
- **`.env` files are never committed.** Use `.env.example` as the template with placeholder values.
|
|
52
52
|
- **Adding a new env var**: Add it to `.env.example` with a comment, and document where it's used.
|
|
53
53
|
- **Keep `.env` and `.env.example` in sync**: Any change to `.env` (adding, removing, or renaming a variable) must be reflected in `.env.example`, and vice versa. They must always have the same set of variables.
|
|
54
|
-
- **Keep `.env.example.production` in sync**: Every time `.env` or `.env.example` changes (variable added, removed, or renamed), also update `.env.example.production` in the same directory. If the file does not exist, create it. This file uses the same variable names but with **production-appropriate placeholder values** and comments (e.g., secure passwords, real domain URLs, SSL-enabled connection strings, stricter timeouts). This applies to both `server/` and `client/` directories.
|
|
54
|
+
- **Keep `.env.example.production` in sync**: Every time `.env` or `.env.example` changes (variable added, removed, or renamed), also update `.env.example.production` in the same directory. If the file does not exist, create it. This file uses the same variable names but with **production-appropriate placeholder values** and comments (e.g., secure passwords, real domain URLs, SSL-enabled connection strings, stricter timeouts). See the server's `.env.example.production` for the expected style. This applies to both `server/` and `client/` directories.
|
|
55
55
|
- **Secrets** (DB URLs, JWT secrets, API keys) must never be prefixed with `NEXT_PUBLIC_` and must never appear in client-side code.
|
|
56
56
|
- **Public values only** (API base URL, app name) get the `NEXT_PUBLIC_` prefix.
|
|
57
57
|
|
|
@@ -63,6 +63,23 @@ When the user asks to create, scaffold, or start a new server or client project,
|
|
|
63
63
|
- **Test naming**: `describe('<ModuleName>')` → `it('should <expected behavior>')`.
|
|
64
64
|
- **Tests must be deterministic**: No reliance on real time, network, or random values. Mock external dependencies.
|
|
65
65
|
- **Don't test implementation details** — test behavior and outputs.
|
|
66
|
+
- **No "mock-echo" tests**: Don't mock a dependency (Prisma/ORM, HTTP client) and then only assert it was called with the same arguments the code passes straight through — that restates the implementation and verifies no behavior. A "was-called-with" assertion is valid only when the layer *transformed* the input first (filtering, pagination math, conditional queries) or when you ALSO assert on the returned value / observable result.
|
|
67
|
+
- **Don't test logic-free layers**: If a unit only forwards to a dependency with no logic of its own (a thin repository/passthrough), don't test it — put the test on the service/behavior layer, where the branches and bugs are.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Deployment Awareness
|
|
72
|
+
|
|
73
|
+
Both server and client are deployed via **Docker on Coolify**. The `Dockerfile` in each directory is the production deployment contract. Code changes must never silently break the Docker build.
|
|
74
|
+
|
|
75
|
+
**Always check deployment impact when you:**
|
|
76
|
+
- Add a native/system npm dependency (needs `apk add` in Dockerfile)
|
|
77
|
+
- Add, remove, or rename a `NEXT_PUBLIC_*` env var (needs `ARG` + `ENV` in client Dockerfile)
|
|
78
|
+
- Change the build output directory, entry point file, or default port
|
|
79
|
+
- Add runtime file dependencies (templates, static assets not in `public/`)
|
|
80
|
+
- Change or remove a health check endpoint
|
|
81
|
+
|
|
82
|
+
**See the deployment rules** in `server/deployment.md` and `client/07-deployment.md` for full details.
|
|
66
83
|
|
|
67
84
|
---
|
|
68
85
|
|
|
@@ -83,3 +100,5 @@ Before considering work done, verify:
|
|
|
83
100
|
- [ ] Inputs validated with Zod.
|
|
84
101
|
- [ ] Error cases handled (not just the happy path).
|
|
85
102
|
- [ ] Existing tests still pass.
|
|
103
|
+
- [ ] No "mock-echo" tests (asserting only that a mock was called, without asserting behavior/output or testing a real transform).
|
|
104
|
+
- [ ] Dockerfile still compatible with changes (see Deployment Awareness above).
|