create-tigra 2.0.1 → 2.0.3
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 +20 -6
- package/package.json +1 -1
- package/template/client/package.json +3 -0
- package/template/server/.env.example +12 -2
- package/template/server/docker-compose.yml +4 -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');
|
|
@@ -222,7 +238,6 @@ async function main() {
|
|
|
222
238
|
console.log();
|
|
223
239
|
console.log(cyan(' 1 ') + 'Install & start infrastructure');
|
|
224
240
|
console.log(dim(' npm install'));
|
|
225
|
-
console.log(dim(' cp .env.example .env'));
|
|
226
241
|
console.log(dim(' npm run docker:up'));
|
|
227
242
|
console.log();
|
|
228
243
|
console.log(cyan(' 2 ') + 'Set up database');
|
|
@@ -236,15 +251,14 @@ async function main() {
|
|
|
236
251
|
console.log();
|
|
237
252
|
console.log(cyan(' 4 ') + 'Start the client');
|
|
238
253
|
console.log(dim(' npm install'));
|
|
239
|
-
console.log(dim(' cp .env.example .env'));
|
|
240
254
|
console.log(dim(' npm run dev'));
|
|
241
255
|
console.log();
|
|
242
256
|
console.log(line);
|
|
243
257
|
console.log();
|
|
244
258
|
console.log(dim(' App ') + cyan('http://localhost:3000'));
|
|
245
259
|
console.log(dim(' API ') + cyan('http://localhost:8000'));
|
|
246
|
-
console.log(dim(' phpMyAdmin ') + cyan(
|
|
247
|
-
console.log(dim(' Redis CMD ') + cyan(
|
|
260
|
+
console.log(dim(' phpMyAdmin ') + cyan(`http://localhost:${variables.PHPMYADMIN_PORT}`));
|
|
261
|
+
console.log(dim(' Redis CMD ') + cyan(`http://localhost:${variables.REDIS_COMMANDER_PORT}`));
|
|
248
262
|
console.log();
|
|
249
263
|
console.log(line);
|
|
250
264
|
console.log();
|
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
|
# ===================================================================
|
|
@@ -9,7 +9,7 @@ services:
|
|
|
9
9
|
container_name: {{PROJECT_NAME}}-mysql
|
|
10
10
|
restart: unless-stopped
|
|
11
11
|
ports:
|
|
12
|
-
- '
|
|
12
|
+
- '${MYSQL_PORT:-{{MYSQL_PORT}}}:3306'
|
|
13
13
|
environment:
|
|
14
14
|
MYSQL_ROOT_PASSWORD: rootpassword
|
|
15
15
|
MYSQL_DATABASE: {{DATABASE_NAME}}
|
|
@@ -28,7 +28,7 @@ services:
|
|
|
28
28
|
container_name: {{PROJECT_NAME}}-phpmyadmin
|
|
29
29
|
restart: unless-stopped
|
|
30
30
|
ports:
|
|
31
|
-
- '
|
|
31
|
+
- '${PHPMYADMIN_PORT:-{{PHPMYADMIN_PORT}}}:80'
|
|
32
32
|
environment:
|
|
33
33
|
PMA_HOST: mysql
|
|
34
34
|
PMA_PORT: 3306
|
|
@@ -48,7 +48,7 @@ services:
|
|
|
48
48
|
container_name: {{PROJECT_NAME}}-redis
|
|
49
49
|
restart: unless-stopped
|
|
50
50
|
ports:
|
|
51
|
-
- '
|
|
51
|
+
- '${REDIS_PORT:-{{REDIS_PORT}}}:6379'
|
|
52
52
|
volumes:
|
|
53
53
|
- redis_data:/data
|
|
54
54
|
networks:
|
|
@@ -64,7 +64,7 @@ services:
|
|
|
64
64
|
container_name: {{PROJECT_NAME}}-redis-commander
|
|
65
65
|
restart: unless-stopped
|
|
66
66
|
ports:
|
|
67
|
-
- '
|
|
67
|
+
- '${REDIS_COMMANDER_PORT:-{{REDIS_COMMANDER_PORT}}}:8081'
|
|
68
68
|
environment:
|
|
69
69
|
REDIS_HOSTS: local:redis:6379
|
|
70
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,
|