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.
- package/bin/create-tigra.js +445 -445
- package/package.json +1 -1
- package/template/_claude/skills/clean-ui/SKILL.md +63 -0
- package/template/_claude/skills/theme/SKILL.md +109 -0
- package/template/client/package.json +47 -47
- package/template/client/src/components/common/Pagination.tsx +10 -2
- package/template/client/src/features/auth/components/LoginForm.tsx +15 -2
- package/template/client/src/features/auth/hooks/useAuth.ts +11 -8
- package/template/client/src/styles/themes/default.css +92 -92
- package/template/server/.env.example +8 -2
- package/template/server/package.json +1 -0
- package/template/server/src/app.ts +1 -0
package/bin/create-tigra.js
CHANGED
|
@@ -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();
|