create-tigra 2.7.1 → 2.7.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.
@@ -1,445 +1,445 @@
1
- #!/usr/bin/env node
2
-
3
- import { Command } from 'commander';
4
- import prompts from 'prompts';
5
- import chalk from 'chalk';
6
- import ora from 'ora';
7
- import fs from 'fs-extra';
8
- import path from 'path';
9
- import { fileURLToPath } from 'url';
10
- import crypto from 'crypto';
11
-
12
- const __filename = fileURLToPath(import.meta.url);
13
- const __dirname = path.dirname(__filename);
14
-
15
- const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
16
- const VERSION = packageJson.version;
17
-
18
- const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
19
-
20
- // Files that contain template variables and need replacement
21
- const FILES_TO_REPLACE = [
22
- 'server/package.json',
23
- 'server/.env.example',
24
- 'server/docker-compose.yml',
25
- 'client/package.json',
26
- 'client/.env.example',
27
- 'server/postman/collection.json',
28
- 'server/postman/environment.json',
29
- ];
30
-
31
- // Directories/files to skip when copying
32
- const SKIP_PATTERNS = [
33
- 'node_modules',
34
- '.next',
35
- 'dist',
36
- 'out',
37
- '.env',
38
- '.env.local',
39
- '.env.*.local',
40
- 'package-lock.json',
41
- 'pnpm-lock.yaml',
42
- 'yarn.lock',
43
- ];
44
-
45
- function toKebabCase(str) {
46
- return str
47
- .toLowerCase()
48
- .replace(/[^a-z0-9-]/g, '-')
49
- .replace(/-+/g, '-')
50
- .replace(/^-|-$/g, '');
51
- }
52
-
53
- function toSnakeCase(str) {
54
- return str.replace(/-/g, '_');
55
- }
56
-
57
- function toTitleCase(str) {
58
- return str
59
- .split('-')
60
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
61
- .join(' ');
62
- }
63
-
64
- function validateProjectName(name) {
65
- if (!name || name.trim().length === 0) {
66
- return 'Project name cannot be empty';
67
- }
68
- const kebab = toKebabCase(name);
69
- if (kebab.length === 0) {
70
- return 'Project name must contain at least one alphanumeric character';
71
- }
72
- if (kebab.length > 214) {
73
- return 'Project name is too long (max 214 characters)';
74
- }
75
- return true;
76
- }
77
-
78
- function shouldSkip(filePath) {
79
- const parts = filePath.split(path.sep);
80
- return parts.some((part) =>
81
- SKIP_PATTERNS.some((pattern) => {
82
- if (pattern.includes('*')) {
83
- const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
84
- return regex.test(part);
85
- }
86
- return part === pattern;
87
- })
88
- );
89
- }
90
-
91
- async function copyTemplate(templateDir, targetDir) {
92
- const entries = await fs.readdir(templateDir, { withFileTypes: true });
93
-
94
- for (const entry of entries) {
95
- const srcPath = path.join(templateDir, entry.name);
96
- const relativePath = path.relative(TEMPLATE_DIR, srcPath);
97
-
98
- if (shouldSkip(relativePath)) {
99
- continue;
100
- }
101
-
102
- // Handle dotfile renaming: gitignore -> .gitignore, _claude -> .claude
103
- let destName = entry.name;
104
- if (entry.name === 'gitignore') destName = '.gitignore';
105
- if (entry.name === '_claude') destName = '.claude';
106
-
107
- const destPath = path.join(targetDir, destName);
108
-
109
- if (entry.isDirectory()) {
110
- await fs.ensureDir(destPath);
111
- await copyTemplate(srcPath, destPath);
112
- } else {
113
- await fs.copy(srcPath, destPath);
114
- }
115
- }
116
- }
117
-
118
- function replaceVariables(content, variables) {
119
- let result = content;
120
- for (const [key, value] of Object.entries(variables)) {
121
- result = result.replaceAll(`{{${key}}}`, value);
122
- }
123
- return result;
124
- }
125
-
126
- function registerAddCommand(program) {
127
- program
128
- .command('add <module>')
129
- .description('Add a module to an existing Tigra project')
130
- .action(async (moduleName) => {
131
- console.log();
132
- console.log(chalk.bold(' Create Tigra') + chalk.dim(` v${VERSION}`) + chalk.dim(' — add module'));
133
- console.log();
134
-
135
- const projectDir = process.cwd();
136
-
137
- // Detect if we're inside a Tigra project
138
- const hasServer = await fs.pathExists(path.join(projectDir, 'server', 'src', 'modules', 'auth'));
139
- const hasClient = await fs.pathExists(path.join(projectDir, 'client', 'src', 'features', 'auth'));
140
-
141
- if (!hasServer || !hasClient) {
142
- console.error(chalk.red(' This does not appear to be a Tigra project.'));
143
- console.error(chalk.dim(' Run this command from the root of your project (the folder containing server/ and client/).'));
144
- console.log();
145
- process.exit(1);
146
- }
147
-
148
- const availableModules = ['email-verification'];
149
-
150
- if (!availableModules.includes(moduleName)) {
151
- console.error(chalk.red(` Unknown module: "${moduleName}"`));
152
- console.log();
153
- console.log(chalk.dim(' Available modules:'));
154
- for (const m of availableModules) {
155
- console.log(chalk.cyan(` - ${m}`));
156
- }
157
- console.log();
158
- process.exit(1);
159
- }
160
-
161
- if (moduleName === 'email-verification') {
162
- // Check if already applied
163
- const alreadyApplied = await fs.pathExists(
164
- path.join(projectDir, 'server', 'src', 'modules', 'auth', 'verification.service.ts'),
165
- );
166
- if (alreadyApplied) {
167
- console.log(chalk.yellow(' Email verification is already installed in this project.'));
168
- console.log();
169
- process.exit(0);
170
- }
171
-
172
- const spinner = ora('Adding email verification module...').start();
173
-
174
- try {
175
- const { applyEmailVerificationModule } = await import(
176
- '../lib/patchers/email-verification.patcher.js'
177
- );
178
- await applyEmailVerificationModule(projectDir);
179
-
180
- // Set REQUIRE_USER_VERIFICATION=true in .env if it's currently false
181
- for (const envFile of ['server/.env.example', 'server/.env']) {
182
- const envPath = path.join(projectDir, envFile);
183
- if (await fs.pathExists(envPath)) {
184
- const content = await fs.readFile(envPath, 'utf-8');
185
- if (content.includes('REQUIRE_USER_VERIFICATION=false')) {
186
- await fs.writeFile(
187
- envPath,
188
- content.replace('REQUIRE_USER_VERIFICATION=false', 'REQUIRE_USER_VERIFICATION=true'),
189
- 'utf-8',
190
- );
191
- }
192
- }
193
- }
194
-
195
- spinner.succeed('Email verification module added!');
196
- } catch (error) {
197
- spinner.fail('Failed to add email verification module');
198
- console.error(chalk.red(`\n ${error.message}\n`));
199
- process.exit(1);
200
- }
201
-
202
- const dim = chalk.dim;
203
- const cyan = chalk.cyan;
204
- const green = chalk.green;
205
-
206
- console.log();
207
- console.log(green(' ✓ ') + 'Files added:');
208
- console.log(dim(' server/src/modules/auth/verification.service.ts'));
209
- console.log(dim(' server/src/modules/auth/verification.controller.ts'));
210
- console.log(dim(' client/src/features/auth/services/verification.service.ts'));
211
- console.log(dim(' client/src/features/auth/hooks/useVerification.ts'));
212
- console.log();
213
- console.log(green(' ✓ ') + 'Files patched:');
214
- console.log(dim(' auth.routes.ts, auth.schemas.ts, auth.service.ts, auth.repo.ts'));
215
- console.log(dim(' rate-limit.config.ts, api-endpoints.ts, error.ts, useAuth.ts'));
216
- console.log(dim(' postman/collection.json'));
217
- console.log();
218
- console.log(green(' ✓ ') + 'REQUIRE_USER_VERIFICATION=true in server/.env');
219
- console.log();
220
- console.log(dim(' Next steps:'));
221
- console.log(cyan(' 1 ') + 'Set ' + chalk.bold('RESEND_API_KEY') + ' in server/.env');
222
- console.log(cyan(' 2 ') + 'Restart the server');
223
- console.log();
224
- }
225
- });
226
- }
227
-
228
- async function main() {
229
- const program = new Command();
230
-
231
- // Define the add subcommand BEFORE the default command so Commander recognizes it
232
- program
233
- .name('create-tigra')
234
- .description('Create a production-ready full-stack app with Next.js + Fastify + Prisma + Redis')
235
- .version(VERSION);
236
-
237
- // ─── Add module subcommand ────────────────────────────────────
238
- registerAddCommand(program);
239
-
240
- // ─── Default: scaffold new project ────────────────────────────
241
- program
242
- .argument('[project-name]', 'Name for your new project')
243
- .action(async (projectNameArg) => {
244
- console.log();
245
- console.log(chalk.bold(' Create Tigra') + chalk.dim(` v${VERSION}`));
246
- console.log();
247
-
248
- let projectName = projectNameArg;
249
-
250
- if (!projectName) {
251
- const response = await prompts(
252
- {
253
- type: 'text',
254
- name: 'projectName',
255
- message: 'What is your project name?',
256
- validate: validateProjectName,
257
- },
258
- {
259
- onCancel: () => {
260
- console.log(chalk.red('\n Cancelled.\n'));
261
- process.exit(1);
262
- },
263
- }
264
- );
265
- projectName = response.projectName;
266
- }
267
-
268
- // Validate and normalize
269
- const validation = validateProjectName(projectName);
270
- if (validation !== true) {
271
- console.error(chalk.red(`\n ${validation}\n`));
272
- process.exit(1);
273
- }
274
-
275
- projectName = toKebabCase(projectName);
276
-
277
- const targetDir = path.resolve(process.cwd(), projectName);
278
-
279
- // Check if directory exists and is non-empty
280
- if (await fs.pathExists(targetDir)) {
281
- const files = await fs.readdir(targetDir);
282
- if (files.length > 0) {
283
- console.error(chalk.red(`\n Directory "${projectName}" already exists and is not empty.\n`));
284
- process.exit(1);
285
- }
286
- }
287
-
288
- // Ask about email verification
289
- const { enableVerification } = await prompts(
290
- {
291
- type: 'toggle',
292
- name: 'enableVerification',
293
- message: 'Enable email verification for new users?',
294
- initial: false,
295
- active: 'Yes',
296
- inactive: 'No',
297
- hint: 'Users must verify email before accessing the app',
298
- },
299
- {
300
- onCancel: () => {
301
- console.log(chalk.red('\n Cancelled.\n'));
302
- process.exit(1);
303
- },
304
- }
305
- );
306
-
307
- // Generate random port offset (1-200) so multiple projects don't conflict
308
- const portOffset = crypto.randomInt(1, 201);
309
-
310
- // Derive all variables
311
- const variables = {
312
- PROJECT_NAME: projectName,
313
- PROJECT_NAME_SNAKE: toSnakeCase(projectName),
314
- PROJECT_DISPLAY_NAME: toTitleCase(projectName),
315
- DATABASE_NAME: `${toSnakeCase(projectName)}_db`,
316
- JWT_SECRET: crypto.randomBytes(48).toString('hex'),
317
- MYSQL_PORT: String(3306 + portOffset),
318
- PHPMYADMIN_PORT: String(8080 + portOffset),
319
- REDIS_PORT: String(6379 + portOffset),
320
- REDIS_COMMANDER_PORT: String(8081 + portOffset),
321
- };
322
-
323
- // Copy template
324
- const spinner = ora('Scaffolding project...').start();
325
-
326
- try {
327
- await fs.ensureDir(targetDir);
328
- await copyTemplate(TEMPLATE_DIR, targetDir);
329
-
330
- // Replace template variables in specific files
331
- for (const filePath of FILES_TO_REPLACE) {
332
- const fullPath = path.join(targetDir, filePath);
333
- if (await fs.pathExists(fullPath)) {
334
- const content = await fs.readFile(fullPath, 'utf-8');
335
- const replaced = replaceVariables(content, variables);
336
- await fs.writeFile(fullPath, replaced, 'utf-8');
337
- }
338
- }
339
-
340
- // Generate .env from .env.example (so users don't have to copy manually)
341
- for (const envExample of ['server/.env.example', 'client/.env.example']) {
342
- const examplePath = path.join(targetDir, envExample);
343
- const envPath = path.join(targetDir, envExample.replace('.env.example', '.env'));
344
- if (await fs.pathExists(examplePath)) {
345
- await fs.copy(examplePath, envPath);
346
- }
347
- }
348
-
349
- // Apply email verification module if selected
350
- if (enableVerification) {
351
- const { applyEmailVerificationModule } = await import('../lib/patchers/email-verification.patcher.js');
352
- await applyEmailVerificationModule(targetDir);
353
- } else {
354
- // Disable verification requirement in .env files
355
- for (const envFile of ['server/.env.example', 'server/.env']) {
356
- const envPath = path.join(targetDir, envFile);
357
- if (await fs.pathExists(envPath)) {
358
- const content = await fs.readFile(envPath, 'utf-8');
359
- await fs.writeFile(
360
- envPath,
361
- content.replace('REQUIRE_USER_VERIFICATION=true', 'REQUIRE_USER_VERIFICATION=false'),
362
- 'utf-8',
363
- );
364
- }
365
- }
366
- }
367
-
368
- // Create .developer-role file (default: fullstack = no restrictions)
369
- const developerRoleContent = [
370
- 'fullstack',
371
- '# Available roles (change the first line to switch):',
372
- '#',
373
- '# frontend - Can edit client/ only. Cannot edit server/ files. Can read everything.',
374
- '# backend - Can edit server/ only. Cannot edit client/ files. Can read everything.',
375
- '# fullstack - Can edit everything. No restrictions.',
376
- '#',
377
- '# You can also switch roles using the /role command in Claude.',
378
- '',
379
- ].join('\n');
380
- await fs.writeFile(path.join(targetDir, '.developer-role'), developerRoleContent, 'utf-8');
381
-
382
- spinner.succeed('Project scaffolded successfully!');
383
- } catch (error) {
384
- spinner.fail('Failed to scaffold project');
385
- console.error(chalk.red(`\n ${error.message}\n`));
386
- process.exit(1);
387
- }
388
-
389
- // Print next steps
390
- const dim = chalk.dim;
391
- const bold = chalk.bold;
392
- const cyan = chalk.cyan;
393
- const green = chalk.green;
394
- const line = dim(' ─────────────────────────────────────────');
395
-
396
- console.log();
397
- console.log(green.bold(' ✓ Created ') + cyan.bold(projectName) + dim(` at ${targetDir}`));
398
- console.log();
399
- console.log(' ┌─────────────────────────────────────────┐');
400
- console.log(' │' + bold(' Getting Started ') + '│');
401
- console.log(' └─────────────────────────────────────────┘');
402
- console.log();
403
- console.log(bold(' SERVER') + dim(' cd ') + cyan(`${projectName}/server`));
404
- console.log();
405
- console.log(cyan(' 1 ') + 'Install & start infrastructure');
406
- console.log(dim(' npm install'));
407
- console.log(dim(' npm run docker:up'));
408
- console.log();
409
- console.log(cyan(' 2 ') + 'Set up database');
410
- console.log(dim(' npm run prisma:generate'));
411
- console.log(dim(' npm run prisma:migrate:dev -- --name init'));
412
- console.log();
413
- console.log(cyan(' 3 ') + 'Start the server');
414
- console.log(dim(' npm run dev'));
415
- console.log();
416
- console.log(bold(' CLIENT') + dim(' (new terminal) cd ') + cyan(`${projectName}/client`));
417
- console.log();
418
- console.log(cyan(' 4 ') + 'Start the client');
419
- console.log(dim(' npm install'));
420
- console.log(dim(' npm run dev'));
421
- console.log();
422
- console.log(line);
423
- console.log();
424
- console.log(dim(' App ') + cyan('http://localhost:3000'));
425
- console.log(dim(' API ') + cyan('http://localhost:8000'));
426
- console.log(dim(' phpMyAdmin ') + cyan(`http://localhost:${variables.PHPMYADMIN_PORT}`));
427
- console.log(dim(' Redis CMD ') + cyan(`http://localhost:${variables.REDIS_COMMANDER_PORT}`));
428
- console.log();
429
- console.log(line);
430
- console.log();
431
- if (enableVerification) {
432
- console.log(dim(' Email verification: ') + green('enabled'));
433
- console.log(dim(' Set RESEND_API_KEY in server/.env to send emails'));
434
- console.log();
435
- }
436
- console.log(dim(' Tip: ') + 'npm run docker:down' + dim(' to stop infrastructure'));
437
- console.log();
438
- console.log(dim(' Happy coding! 🚀'));
439
- console.log();
440
- });
441
-
442
- program.parse();
443
- }
444
-
445
- main();
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import prompts from 'prompts';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import fs from 'fs-extra';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import crypto from 'crypto';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
16
+ const VERSION = packageJson.version;
17
+
18
+ const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
19
+
20
+ // Files that contain template variables and need replacement
21
+ const FILES_TO_REPLACE = [
22
+ 'server/package.json',
23
+ 'server/.env.example',
24
+ 'server/docker-compose.yml',
25
+ 'client/package.json',
26
+ 'client/.env.example',
27
+ 'server/postman/collection.json',
28
+ 'server/postman/environment.json',
29
+ ];
30
+
31
+ // Directories/files to skip when copying
32
+ const SKIP_PATTERNS = [
33
+ 'node_modules',
34
+ '.next',
35
+ 'dist',
36
+ 'out',
37
+ '.env',
38
+ '.env.local',
39
+ '.env.*.local',
40
+ 'package-lock.json',
41
+ 'pnpm-lock.yaml',
42
+ 'yarn.lock',
43
+ ];
44
+
45
+ function toKebabCase(str) {
46
+ return str
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9-]/g, '-')
49
+ .replace(/-+/g, '-')
50
+ .replace(/^-|-$/g, '');
51
+ }
52
+
53
+ function toSnakeCase(str) {
54
+ return str.replace(/-/g, '_');
55
+ }
56
+
57
+ function toTitleCase(str) {
58
+ return str
59
+ .split('-')
60
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
61
+ .join(' ');
62
+ }
63
+
64
+ function validateProjectName(name) {
65
+ if (!name || name.trim().length === 0) {
66
+ return 'Project name cannot be empty';
67
+ }
68
+ const kebab = toKebabCase(name);
69
+ if (kebab.length === 0) {
70
+ return 'Project name must contain at least one alphanumeric character';
71
+ }
72
+ if (kebab.length > 214) {
73
+ return 'Project name is too long (max 214 characters)';
74
+ }
75
+ return true;
76
+ }
77
+
78
+ function shouldSkip(filePath) {
79
+ const parts = filePath.split(path.sep);
80
+ return parts.some((part) =>
81
+ SKIP_PATTERNS.some((pattern) => {
82
+ if (pattern.includes('*')) {
83
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
84
+ return regex.test(part);
85
+ }
86
+ return part === pattern;
87
+ })
88
+ );
89
+ }
90
+
91
+ async function copyTemplate(templateDir, targetDir) {
92
+ const entries = await fs.readdir(templateDir, { withFileTypes: true });
93
+
94
+ for (const entry of entries) {
95
+ const srcPath = path.join(templateDir, entry.name);
96
+ const relativePath = path.relative(TEMPLATE_DIR, srcPath);
97
+
98
+ if (shouldSkip(relativePath)) {
99
+ continue;
100
+ }
101
+
102
+ // Handle dotfile renaming: gitignore -> .gitignore, _claude -> .claude
103
+ let destName = entry.name;
104
+ if (entry.name === 'gitignore') destName = '.gitignore';
105
+ if (entry.name === '_claude') destName = '.claude';
106
+
107
+ const destPath = path.join(targetDir, destName);
108
+
109
+ if (entry.isDirectory()) {
110
+ await fs.ensureDir(destPath);
111
+ await copyTemplate(srcPath, destPath);
112
+ } else {
113
+ await fs.copy(srcPath, destPath);
114
+ }
115
+ }
116
+ }
117
+
118
+ function replaceVariables(content, variables) {
119
+ let result = content;
120
+ for (const [key, value] of Object.entries(variables)) {
121
+ result = result.replaceAll(`{{${key}}}`, value);
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function registerAddCommand(program) {
127
+ program
128
+ .command('add <module>')
129
+ .description('Add a module to an existing Tigra project')
130
+ .action(async (moduleName) => {
131
+ console.log();
132
+ console.log(chalk.bold(' Create Tigra') + chalk.dim(` v${VERSION}`) + chalk.dim(' — add module'));
133
+ console.log();
134
+
135
+ const projectDir = process.cwd();
136
+
137
+ // Detect if we're inside a Tigra project
138
+ const hasServer = await fs.pathExists(path.join(projectDir, 'server', 'src', 'modules', 'auth'));
139
+ const hasClient = await fs.pathExists(path.join(projectDir, 'client', 'src', 'features', 'auth'));
140
+
141
+ if (!hasServer || !hasClient) {
142
+ console.error(chalk.red(' This does not appear to be a Tigra project.'));
143
+ console.error(chalk.dim(' Run this command from the root of your project (the folder containing server/ and client/).'));
144
+ console.log();
145
+ process.exit(1);
146
+ }
147
+
148
+ const availableModules = ['email-verification'];
149
+
150
+ if (!availableModules.includes(moduleName)) {
151
+ console.error(chalk.red(` Unknown module: "${moduleName}"`));
152
+ console.log();
153
+ console.log(chalk.dim(' Available modules:'));
154
+ for (const m of availableModules) {
155
+ console.log(chalk.cyan(` - ${m}`));
156
+ }
157
+ console.log();
158
+ process.exit(1);
159
+ }
160
+
161
+ if (moduleName === 'email-verification') {
162
+ // Check if already applied
163
+ const alreadyApplied = await fs.pathExists(
164
+ path.join(projectDir, 'server', 'src', 'modules', 'auth', 'verification.service.ts'),
165
+ );
166
+ if (alreadyApplied) {
167
+ console.log(chalk.yellow(' Email verification is already installed in this project.'));
168
+ console.log();
169
+ process.exit(0);
170
+ }
171
+
172
+ const spinner = ora('Adding email verification module...').start();
173
+
174
+ try {
175
+ const { applyEmailVerificationModule } = await import(
176
+ '../lib/patchers/email-verification.patcher.js'
177
+ );
178
+ await applyEmailVerificationModule(projectDir);
179
+
180
+ // Set REQUIRE_USER_VERIFICATION=true in .env if it's currently false
181
+ for (const envFile of ['server/.env.example', 'server/.env']) {
182
+ const envPath = path.join(projectDir, envFile);
183
+ if (await fs.pathExists(envPath)) {
184
+ const content = await fs.readFile(envPath, 'utf-8');
185
+ if (content.includes('REQUIRE_USER_VERIFICATION=false')) {
186
+ await fs.writeFile(
187
+ envPath,
188
+ content.replace('REQUIRE_USER_VERIFICATION=false', 'REQUIRE_USER_VERIFICATION=true'),
189
+ 'utf-8',
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ spinner.succeed('Email verification module added!');
196
+ } catch (error) {
197
+ spinner.fail('Failed to add email verification module');
198
+ console.error(chalk.red(`\n ${error.message}\n`));
199
+ process.exit(1);
200
+ }
201
+
202
+ const dim = chalk.dim;
203
+ const cyan = chalk.cyan;
204
+ const green = chalk.green;
205
+
206
+ console.log();
207
+ console.log(green(' ✓ ') + 'Files added:');
208
+ console.log(dim(' server/src/modules/auth/verification.service.ts'));
209
+ console.log(dim(' server/src/modules/auth/verification.controller.ts'));
210
+ console.log(dim(' client/src/features/auth/services/verification.service.ts'));
211
+ console.log(dim(' client/src/features/auth/hooks/useVerification.ts'));
212
+ console.log();
213
+ console.log(green(' ✓ ') + 'Files patched:');
214
+ console.log(dim(' auth.routes.ts, auth.schemas.ts, auth.service.ts, auth.repo.ts'));
215
+ console.log(dim(' rate-limit.config.ts, api-endpoints.ts, error.ts, useAuth.ts'));
216
+ console.log(dim(' postman/collection.json'));
217
+ console.log();
218
+ console.log(green(' ✓ ') + 'REQUIRE_USER_VERIFICATION=true in server/.env');
219
+ console.log();
220
+ console.log(dim(' Next steps:'));
221
+ console.log(cyan(' 1 ') + 'Set ' + chalk.bold('RESEND_API_KEY') + ' in server/.env');
222
+ console.log(cyan(' 2 ') + 'Restart the server');
223
+ console.log();
224
+ }
225
+ });
226
+ }
227
+
228
+ async function main() {
229
+ const program = new Command();
230
+
231
+ // Define the add subcommand BEFORE the default command so Commander recognizes it
232
+ program
233
+ .name('create-tigra')
234
+ .description('Create a production-ready full-stack app with Next.js + Fastify + Prisma + Redis')
235
+ .version(VERSION);
236
+
237
+ // ─── Add module subcommand ────────────────────────────────────
238
+ registerAddCommand(program);
239
+
240
+ // ─── Default: scaffold new project ────────────────────────────
241
+ program
242
+ .argument('[project-name]', 'Name for your new project')
243
+ .action(async (projectNameArg) => {
244
+ console.log();
245
+ console.log(chalk.bold(' Create Tigra') + chalk.dim(` v${VERSION}`));
246
+ console.log();
247
+
248
+ let projectName = projectNameArg;
249
+
250
+ if (!projectName) {
251
+ const response = await prompts(
252
+ {
253
+ type: 'text',
254
+ name: 'projectName',
255
+ message: 'What is your project name?',
256
+ validate: validateProjectName,
257
+ },
258
+ {
259
+ onCancel: () => {
260
+ console.log(chalk.red('\n Cancelled.\n'));
261
+ process.exit(1);
262
+ },
263
+ }
264
+ );
265
+ projectName = response.projectName;
266
+ }
267
+
268
+ // Validate and normalize
269
+ const validation = validateProjectName(projectName);
270
+ if (validation !== true) {
271
+ console.error(chalk.red(`\n ${validation}\n`));
272
+ process.exit(1);
273
+ }
274
+
275
+ projectName = toKebabCase(projectName);
276
+
277
+ const targetDir = path.resolve(process.cwd(), projectName);
278
+
279
+ // Check if directory exists and is non-empty
280
+ if (await fs.pathExists(targetDir)) {
281
+ const files = await fs.readdir(targetDir);
282
+ if (files.length > 0) {
283
+ console.error(chalk.red(`\n Directory "${projectName}" already exists and is not empty.\n`));
284
+ process.exit(1);
285
+ }
286
+ }
287
+
288
+ // Ask about email verification
289
+ const { enableVerification } = await prompts(
290
+ {
291
+ type: 'toggle',
292
+ name: 'enableVerification',
293
+ message: 'Enable email verification for new users?',
294
+ initial: false,
295
+ active: 'Yes',
296
+ inactive: 'No',
297
+ hint: 'Users must verify email before accessing the app',
298
+ },
299
+ {
300
+ onCancel: () => {
301
+ console.log(chalk.red('\n Cancelled.\n'));
302
+ process.exit(1);
303
+ },
304
+ }
305
+ );
306
+
307
+ // Generate random port offset (1-200) so multiple projects don't conflict
308
+ const portOffset = crypto.randomInt(1, 201);
309
+
310
+ // Derive all variables
311
+ const variables = {
312
+ PROJECT_NAME: projectName,
313
+ PROJECT_NAME_SNAKE: toSnakeCase(projectName),
314
+ PROJECT_DISPLAY_NAME: toTitleCase(projectName),
315
+ DATABASE_NAME: `${toSnakeCase(projectName)}_db`,
316
+ JWT_SECRET: crypto.randomBytes(48).toString('hex'),
317
+ MYSQL_PORT: String(3306 + portOffset),
318
+ PHPMYADMIN_PORT: String(8080 + portOffset),
319
+ REDIS_PORT: String(6379 + portOffset),
320
+ REDIS_COMMANDER_PORT: String(8081 + portOffset),
321
+ };
322
+
323
+ // Copy template
324
+ const spinner = ora('Scaffolding project...').start();
325
+
326
+ try {
327
+ await fs.ensureDir(targetDir);
328
+ await copyTemplate(TEMPLATE_DIR, targetDir);
329
+
330
+ // Replace template variables in specific files
331
+ for (const filePath of FILES_TO_REPLACE) {
332
+ const fullPath = path.join(targetDir, filePath);
333
+ if (await fs.pathExists(fullPath)) {
334
+ const content = await fs.readFile(fullPath, 'utf-8');
335
+ const replaced = replaceVariables(content, variables);
336
+ await fs.writeFile(fullPath, replaced, 'utf-8');
337
+ }
338
+ }
339
+
340
+ // Generate .env from .env.example (so users don't have to copy manually)
341
+ for (const envExample of ['server/.env.example', 'client/.env.example']) {
342
+ const examplePath = path.join(targetDir, envExample);
343
+ const envPath = path.join(targetDir, envExample.replace('.env.example', '.env'));
344
+ if (await fs.pathExists(examplePath)) {
345
+ await fs.copy(examplePath, envPath);
346
+ }
347
+ }
348
+
349
+ // Apply email verification module if selected
350
+ if (enableVerification) {
351
+ const { applyEmailVerificationModule } = await import('../lib/patchers/email-verification.patcher.js');
352
+ await applyEmailVerificationModule(targetDir);
353
+ } else {
354
+ // Disable verification requirement in .env files
355
+ for (const envFile of ['server/.env.example', 'server/.env']) {
356
+ const envPath = path.join(targetDir, envFile);
357
+ if (await fs.pathExists(envPath)) {
358
+ const content = await fs.readFile(envPath, 'utf-8');
359
+ await fs.writeFile(
360
+ envPath,
361
+ content.replace('REQUIRE_USER_VERIFICATION=true', 'REQUIRE_USER_VERIFICATION=false'),
362
+ 'utf-8',
363
+ );
364
+ }
365
+ }
366
+ }
367
+
368
+ // Create .developer-role file (default: fullstack = no restrictions)
369
+ const developerRoleContent = [
370
+ 'fullstack',
371
+ '# Available roles (change the first line to switch):',
372
+ '#',
373
+ '# frontend - Can edit client/ only. Cannot edit server/ files. Can read everything.',
374
+ '# backend - Can edit server/ only. Cannot edit client/ files. Can read everything.',
375
+ '# fullstack - Can edit everything. No restrictions.',
376
+ '#',
377
+ '# You can also switch roles using the /role command in Claude.',
378
+ '',
379
+ ].join('\n');
380
+ await fs.writeFile(path.join(targetDir, '.developer-role'), developerRoleContent, 'utf-8');
381
+
382
+ spinner.succeed('Project scaffolded successfully!');
383
+ } catch (error) {
384
+ spinner.fail('Failed to scaffold project');
385
+ console.error(chalk.red(`\n ${error.message}\n`));
386
+ process.exit(1);
387
+ }
388
+
389
+ // Print next steps
390
+ const dim = chalk.dim;
391
+ const bold = chalk.bold;
392
+ const cyan = chalk.cyan;
393
+ const green = chalk.green;
394
+ const line = dim(' ─────────────────────────────────────────');
395
+
396
+ console.log();
397
+ console.log(green.bold(' ✓ Created ') + cyan.bold(projectName) + dim(` at ${targetDir}`));
398
+ console.log();
399
+ console.log(' ┌─────────────────────────────────────────┐');
400
+ console.log(' │' + bold(' Getting Started ') + '│');
401
+ console.log(' └─────────────────────────────────────────┘');
402
+ console.log();
403
+ console.log(bold(' SERVER') + dim(' cd ') + cyan(`${projectName}/server`));
404
+ console.log();
405
+ console.log(cyan(' 1 ') + 'Install & start infrastructure');
406
+ console.log(dim(' npm install'));
407
+ console.log(dim(' npm run docker:up'));
408
+ console.log();
409
+ console.log(cyan(' 2 ') + 'Set up database');
410
+ console.log(dim(' npm run prisma:generate'));
411
+ console.log(dim(' npm run prisma:migrate:dev -- --name init'));
412
+ console.log();
413
+ console.log(cyan(' 3 ') + 'Start the server');
414
+ console.log(dim(' npm run dev'));
415
+ console.log();
416
+ console.log(bold(' CLIENT') + dim(' (new terminal) cd ') + cyan(`${projectName}/client`));
417
+ console.log();
418
+ console.log(cyan(' 4 ') + 'Start the client');
419
+ console.log(dim(' npm install'));
420
+ console.log(dim(' npm run dev'));
421
+ console.log();
422
+ console.log(line);
423
+ console.log();
424
+ console.log(dim(' App ') + cyan('http://localhost:3000'));
425
+ console.log(dim(' API ') + cyan('http://localhost:8000'));
426
+ console.log(dim(' phpMyAdmin ') + cyan(`http://localhost:${variables.PHPMYADMIN_PORT}`));
427
+ console.log(dim(' Redis CMD ') + cyan(`http://localhost:${variables.REDIS_COMMANDER_PORT}`));
428
+ console.log();
429
+ console.log(line);
430
+ console.log();
431
+ if (enableVerification) {
432
+ console.log(dim(' Email verification: ') + green('enabled'));
433
+ console.log(dim(' Set RESEND_API_KEY in server/.env to send emails'));
434
+ console.log();
435
+ }
436
+ console.log(dim(' Tip: ') + 'npm run docker:down' + dim(' to stop infrastructure'));
437
+ console.log();
438
+ console.log(dim(' Happy coding! 🚀'));
439
+ console.log();
440
+ });
441
+
442
+ program.parse();
443
+ }
444
+
445
+ main();