create-tigra 2.0.0 → 2.0.2
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/bin/create-tigra.js +54 -22
- package/package.json +1 -1
- package/template/server/.env.example +12 -2
- package/template/server/docker-compose.yml +6 -4
- package/template/server/package.json +9 -6
- package/template/server/src/libs/password.ts +3 -18
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +2 -2
- package/template/server/src/modules/auth/auth.service.ts +1 -7
package/bin/create-tigra.js
CHANGED
|
@@ -124,11 +124,11 @@ async function main() {
|
|
|
124
124
|
program
|
|
125
125
|
.name('create-tigra')
|
|
126
126
|
.description('Create a production-ready full-stack app with Next.js + Fastify + Prisma + Redis')
|
|
127
|
-
.version('2.0.
|
|
127
|
+
.version('2.0.2')
|
|
128
128
|
.argument('[project-name]', 'Name for your new project')
|
|
129
129
|
.action(async (projectNameArg) => {
|
|
130
130
|
console.log();
|
|
131
|
-
console.log(chalk.bold(' Create Tigra') + chalk.dim(' v2.0.
|
|
131
|
+
console.log(chalk.bold(' Create Tigra') + chalk.dim(' v2.0.2'));
|
|
132
132
|
console.log();
|
|
133
133
|
|
|
134
134
|
let projectName = projectNameArg;
|
|
@@ -171,6 +171,9 @@ async function main() {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Generate random port offset (1-200) so multiple projects don't conflict
|
|
175
|
+
const portOffset = crypto.randomInt(1, 201);
|
|
176
|
+
|
|
174
177
|
// Derive all variables
|
|
175
178
|
const variables = {
|
|
176
179
|
PROJECT_NAME: projectName,
|
|
@@ -178,6 +181,10 @@ async function main() {
|
|
|
178
181
|
PROJECT_DISPLAY_NAME: toTitleCase(projectName),
|
|
179
182
|
DATABASE_NAME: `${toSnakeCase(projectName)}_db`,
|
|
180
183
|
JWT_SECRET: crypto.randomBytes(48).toString('hex'),
|
|
184
|
+
MYSQL_PORT: String(3306 + portOffset),
|
|
185
|
+
PHPMYADMIN_PORT: String(8080 + portOffset),
|
|
186
|
+
REDIS_PORT: String(6379 + portOffset),
|
|
187
|
+
REDIS_COMMANDER_PORT: String(8081 + portOffset),
|
|
181
188
|
};
|
|
182
189
|
|
|
183
190
|
// Copy template
|
|
@@ -197,6 +204,15 @@ async function main() {
|
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
// Generate .env from .env.example (so users don't have to copy manually)
|
|
208
|
+
for (const envExample of ['server/.env.example', 'client/.env.example']) {
|
|
209
|
+
const examplePath = path.join(targetDir, envExample);
|
|
210
|
+
const envPath = path.join(targetDir, envExample.replace('.env.example', '.env'));
|
|
211
|
+
if (await fs.pathExists(examplePath)) {
|
|
212
|
+
await fs.copy(examplePath, envPath);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
200
216
|
spinner.succeed('Project scaffolded successfully!');
|
|
201
217
|
} catch (error) {
|
|
202
218
|
spinner.fail('Failed to scaffold project');
|
|
@@ -205,34 +221,50 @@ async function main() {
|
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
// Print next steps
|
|
224
|
+
const dim = chalk.dim;
|
|
225
|
+
const bold = chalk.bold;
|
|
226
|
+
const cyan = chalk.cyan;
|
|
227
|
+
const green = chalk.green;
|
|
228
|
+
const line = dim(' ─────────────────────────────────────────');
|
|
229
|
+
|
|
230
|
+
console.log();
|
|
231
|
+
console.log(green.bold(' ✓ Created ') + cyan.bold(projectName) + dim(` at ${targetDir}`));
|
|
232
|
+
console.log();
|
|
233
|
+
console.log(' ┌─────────────────────────────────────────┐');
|
|
234
|
+
console.log(' │' + bold(' Getting Started ') + '│');
|
|
235
|
+
console.log(' └─────────────────────────────────────────┘');
|
|
236
|
+
console.log();
|
|
237
|
+
console.log(bold(' SERVER') + dim(' cd ') + cyan(`${projectName}/server`));
|
|
238
|
+
console.log();
|
|
239
|
+
console.log(cyan(' 1 ') + 'Install & start infrastructure');
|
|
240
|
+
console.log(dim(' npm install'));
|
|
241
|
+
console.log(dim(' npm run docker:up'));
|
|
242
|
+
console.log();
|
|
243
|
+
console.log(cyan(' 2 ') + 'Set up database');
|
|
244
|
+
console.log(dim(' npm run prisma:generate'));
|
|
245
|
+
console.log(dim(' npm run prisma:migrate:dev -- --name init'));
|
|
208
246
|
console.log();
|
|
209
|
-
console.log(
|
|
247
|
+
console.log(cyan(' 3 ') + 'Start the server');
|
|
248
|
+
console.log(dim(' npm run dev'));
|
|
210
249
|
console.log();
|
|
211
|
-
console.log(
|
|
250
|
+
console.log(bold(' CLIENT') + dim(' (new terminal) cd ') + cyan(`${projectName}/client`));
|
|
212
251
|
console.log();
|
|
213
|
-
console.log(
|
|
214
|
-
console.log(
|
|
215
|
-
console.log(
|
|
252
|
+
console.log(cyan(' 4 ') + 'Start the client');
|
|
253
|
+
console.log(dim(' npm install'));
|
|
254
|
+
console.log(dim(' npm run dev'));
|
|
216
255
|
console.log();
|
|
217
|
-
console.log(
|
|
218
|
-
console.log(chalk.dim(' npm install'));
|
|
219
|
-
console.log(chalk.dim(' cp .env.example .env'));
|
|
220
|
-
console.log(chalk.dim(' npm run prisma:generate'));
|
|
221
|
-
console.log(chalk.dim(' npm run prisma:migrate:dev -- --name init'));
|
|
256
|
+
console.log(line);
|
|
222
257
|
console.log();
|
|
223
|
-
console.log(
|
|
224
|
-
console.log(
|
|
258
|
+
console.log(dim(' App ') + cyan('http://localhost:3000'));
|
|
259
|
+
console.log(dim(' API ') + cyan('http://localhost:8000'));
|
|
260
|
+
console.log(dim(' phpMyAdmin ') + cyan(`http://localhost:${variables.PHPMYADMIN_PORT}`));
|
|
261
|
+
console.log(dim(' Redis CMD ') + cyan(`http://localhost:${variables.REDIS_COMMANDER_PORT}`));
|
|
225
262
|
console.log();
|
|
226
|
-
console.log(
|
|
227
|
-
console.log(chalk.dim(` cd ${projectName}/client`));
|
|
228
|
-
console.log(chalk.dim(' npm install'));
|
|
229
|
-
console.log(chalk.dim(' cp .env.example .env'));
|
|
230
|
-
console.log(chalk.dim(' npm run dev'));
|
|
263
|
+
console.log(line);
|
|
231
264
|
console.log();
|
|
232
|
-
console.log(
|
|
233
|
-
console.log(chalk.dim(' Client running at: ') + chalk.cyan('http://localhost:3000'));
|
|
265
|
+
console.log(dim(' Tip: ') + 'npm run docker:down' + dim(' to stop infrastructure'));
|
|
234
266
|
console.log();
|
|
235
|
-
console.log(
|
|
267
|
+
console.log(dim(' Happy coding! 🚀'));
|
|
236
268
|
console.log();
|
|
237
269
|
});
|
|
238
270
|
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ HOST=0.0.0.0
|
|
|
17
17
|
|
|
18
18
|
# Database connection string
|
|
19
19
|
# Format: mysql://username:password@host:port/database
|
|
20
|
-
DATABASE_URL="mysql://root:rootpassword@localhost:
|
|
20
|
+
DATABASE_URL="mysql://root:rootpassword@localhost:{{MYSQL_PORT}}/{{DATABASE_NAME}}"
|
|
21
21
|
|
|
22
22
|
# Connection pool settings (for high-traffic production)
|
|
23
23
|
# Min connections: 2-5 for low traffic, 5-10 for medium, 10-20 for high
|
|
@@ -31,7 +31,7 @@ DATABASE_POOL_MAX=10
|
|
|
31
31
|
|
|
32
32
|
# Redis connection URL
|
|
33
33
|
# Format: redis://[:password@]host:port[/database]
|
|
34
|
-
REDIS_URL="redis://localhost:
|
|
34
|
+
REDIS_URL="redis://localhost:{{REDIS_PORT}}"
|
|
35
35
|
|
|
36
36
|
# Max retry attempts for failed Redis operations
|
|
37
37
|
REDIS_MAX_RETRIES=3
|
|
@@ -39,6 +39,16 @@ REDIS_MAX_RETRIES=3
|
|
|
39
39
|
# Connection timeout in milliseconds
|
|
40
40
|
REDIS_CONNECT_TIMEOUT=10000
|
|
41
41
|
|
|
42
|
+
# ===================================================================
|
|
43
|
+
# DOCKER PORTS (auto-generated, unique per project)
|
|
44
|
+
# ===================================================================
|
|
45
|
+
|
|
46
|
+
# Change these if they conflict with other services on your machine
|
|
47
|
+
MYSQL_PORT={{MYSQL_PORT}}
|
|
48
|
+
PHPMYADMIN_PORT={{PHPMYADMIN_PORT}}
|
|
49
|
+
REDIS_PORT={{REDIS_PORT}}
|
|
50
|
+
REDIS_COMMANDER_PORT={{REDIS_COMMANDER_PORT}}
|
|
51
|
+
|
|
42
52
|
# ===================================================================
|
|
43
53
|
# JWT AUTHENTICATION
|
|
44
54
|
# ===================================================================
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
# WARNING: These credentials are for LOCAL DEVELOPMENT ONLY.
|
|
2
2
|
# Change all passwords and secrets before using in any shared or production environment.
|
|
3
3
|
|
|
4
|
+
name: {{PROJECT_NAME}}
|
|
5
|
+
|
|
4
6
|
services:
|
|
5
7
|
mysql:
|
|
6
8
|
image: mysql:8.0
|
|
7
9
|
container_name: {{PROJECT_NAME}}-mysql
|
|
8
10
|
restart: unless-stopped
|
|
9
11
|
ports:
|
|
10
|
-
- '
|
|
12
|
+
- '${MYSQL_PORT:-{{MYSQL_PORT}}}:3306'
|
|
11
13
|
environment:
|
|
12
14
|
MYSQL_ROOT_PASSWORD: rootpassword
|
|
13
15
|
MYSQL_DATABASE: {{DATABASE_NAME}}
|
|
@@ -26,7 +28,7 @@ services:
|
|
|
26
28
|
container_name: {{PROJECT_NAME}}-phpmyadmin
|
|
27
29
|
restart: unless-stopped
|
|
28
30
|
ports:
|
|
29
|
-
- '
|
|
31
|
+
- '${PHPMYADMIN_PORT:-{{PHPMYADMIN_PORT}}}:80'
|
|
30
32
|
environment:
|
|
31
33
|
PMA_HOST: mysql
|
|
32
34
|
PMA_PORT: 3306
|
|
@@ -46,7 +48,7 @@ services:
|
|
|
46
48
|
container_name: {{PROJECT_NAME}}-redis
|
|
47
49
|
restart: unless-stopped
|
|
48
50
|
ports:
|
|
49
|
-
- '
|
|
51
|
+
- '${REDIS_PORT:-{{REDIS_PORT}}}:6379'
|
|
50
52
|
volumes:
|
|
51
53
|
- redis_data:/data
|
|
52
54
|
networks:
|
|
@@ -62,7 +64,7 @@ services:
|
|
|
62
64
|
container_name: {{PROJECT_NAME}}-redis-commander
|
|
63
65
|
restart: unless-stopped
|
|
64
66
|
ports:
|
|
65
|
-
- '
|
|
67
|
+
- '${REDIS_COMMANDER_PORT:-{{REDIS_COMMANDER_PORT}}}:8081'
|
|
66
68
|
environment:
|
|
67
69
|
REDIS_HOSTS: local:redis:6379
|
|
68
70
|
depends_on:
|
|
@@ -35,10 +35,9 @@
|
|
|
35
35
|
"@fastify/jwt": "^10.0.0",
|
|
36
36
|
"@fastify/multipart": "^9.0.2",
|
|
37
37
|
"@fastify/rate-limit": "^10.3.0",
|
|
38
|
-
"@fastify/static": "^
|
|
38
|
+
"@fastify/static": "^9.0.0",
|
|
39
39
|
"@prisma/client": "^6.19.2",
|
|
40
40
|
"argon2": "^0.44.0",
|
|
41
|
-
"bcryptjs": "^2.4.3",
|
|
42
41
|
"dotenv": "^16.4.7",
|
|
43
42
|
"fastify": "^5.7.4",
|
|
44
43
|
"fastify-type-provider-zod": "^6.1.0",
|
|
@@ -49,15 +48,19 @@
|
|
|
49
48
|
"uuid": "^10.0.0",
|
|
50
49
|
"zod": "^4.3.6"
|
|
51
50
|
},
|
|
51
|
+
"overrides": {
|
|
52
|
+
"bn.js": ">=5.2.3",
|
|
53
|
+
"@typescript-eslint/typescript-estree": {
|
|
54
|
+
"minimatch": ">=10.2.1"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
52
57
|
"devDependencies": {
|
|
53
|
-
"@eslint/js": "^
|
|
54
|
-
"@types/bcryptjs": "^2.4.6",
|
|
58
|
+
"@eslint/js": "^10.0.1",
|
|
55
59
|
"@types/node": "^20.17.10",
|
|
56
|
-
"@types/sharp": "^0.32.0",
|
|
57
60
|
"@types/uuid": "^10.0.0",
|
|
58
61
|
"@vitest/coverage-v8": "^4.0.18",
|
|
59
62
|
"@vitest/ui": "^4.0.18",
|
|
60
|
-
"eslint": "^
|
|
63
|
+
"eslint": "^10.0.1",
|
|
61
64
|
"prisma": "^6.19.2",
|
|
62
65
|
"tsc-alias": "^1.8.16",
|
|
63
66
|
"tsx": "^4.21.0",
|
|
@@ -16,23 +16,8 @@ export async function hashPassword(password: string): Promise<string> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Verify a password against
|
|
20
|
-
* Supports both argon2 and legacy bcrypt hashes.
|
|
21
|
-
* Returns { valid, needsRehash } so the caller can transparently upgrade bcrypt hashes.
|
|
19
|
+
* Verify a password against an argon2 hash
|
|
22
20
|
*/
|
|
23
|
-
export async function verifyPassword(
|
|
24
|
-
|
|
25
|
-
hash: string,
|
|
26
|
-
): Promise<{ valid: boolean; needsRehash: boolean }> {
|
|
27
|
-
const isBcryptHash = hash.startsWith('$2a$') || hash.startsWith('$2b$') || hash.startsWith('$2y$');
|
|
28
|
-
|
|
29
|
-
if (isBcryptHash) {
|
|
30
|
-
// Lazy-import bcryptjs only for legacy hash verification
|
|
31
|
-
const bcrypt = await import('bcryptjs');
|
|
32
|
-
const valid = await bcrypt.default.compare(password, hash);
|
|
33
|
-
return { valid, needsRehash: valid }; // rehash only if password is correct
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const valid = await argon2.verify(hash, password);
|
|
37
|
-
return { valid, needsRehash: false };
|
|
21
|
+
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
22
|
+
return argon2.verify(hash, password);
|
|
38
23
|
}
|
|
@@ -108,7 +108,7 @@ describe('Auth Service', () => {
|
|
|
108
108
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
109
109
|
|
|
110
110
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
111
|
-
vi.mocked(verifyPassword).mockResolvedValue(
|
|
111
|
+
vi.mocked(verifyPassword).mockResolvedValue(true);
|
|
112
112
|
vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
|
|
113
113
|
vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
|
|
114
114
|
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
@@ -154,7 +154,7 @@ describe('Auth Service', () => {
|
|
|
154
154
|
it('should throw UnauthorizedError if password is invalid', async () => {
|
|
155
155
|
// Arrange
|
|
156
156
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
157
|
-
vi.mocked(verifyPassword).mockResolvedValue(
|
|
157
|
+
vi.mocked(verifyPassword).mockResolvedValue(false);
|
|
158
158
|
|
|
159
159
|
// Act & Assert
|
|
160
160
|
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
@@ -132,7 +132,7 @@ export async function login(
|
|
|
132
132
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
const
|
|
135
|
+
const valid = await verifyPassword(input.password, user.password);
|
|
136
136
|
|
|
137
137
|
if (!valid) {
|
|
138
138
|
// Increment failed attempts
|
|
@@ -153,12 +153,6 @@ export async function login(
|
|
|
153
153
|
await authRepo.resetFailedAttempts(user.id);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// Transparent rehash: upgrade bcrypt → argon2id
|
|
157
|
-
if (needsRehash) {
|
|
158
|
-
const newHash = await hashPassword(input.password);
|
|
159
|
-
await authRepo.updateUserPassword(user.id, newHash);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
156
|
const accessToken = signAccessToken({
|
|
163
157
|
userId: user.id,
|
|
164
158
|
role: user.role,
|