novatec-cli 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.js CHANGED
@@ -1,238 +1,178 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  import { program } from 'commander';
4
3
  import chalk from 'chalk';
5
4
  import figlet from 'figlet';
6
5
  import boxen from 'boxen';
7
- import { execSync } from 'child_process';
6
+ import { execSync, spawnSync } from 'child_process';
8
7
  import { createRequire } from 'module';
9
- import { checkRequirements } from '../lib/utils.js';
10
- import { runCreate } from '../lib/create.js';
11
8
 
12
9
  const require = createRequire(import.meta.url);
13
10
  const pkg = require('../package.json');
14
11
 
15
- function sleep(ms) {
16
- return new Promise(r => setTimeout(r, ms));
17
- }
18
-
19
- // Verifica si hay una versión más nueva en npm (silencioso)
20
12
  async function checkForUpdates() {
21
13
  try {
22
14
  const latest = execSync(`npm show ${pkg.name} version`, { encoding: 'utf8', timeout: 3000 }).trim();
23
15
  if (latest && latest !== pkg.version) {
24
- console.log(
25
- boxen(
26
- chalk.yellow(' Nueva versión disponible!\n\n') +
27
- chalk.gray(' Actual : ') + chalk.white(pkg.version) + '\n' +
28
- chalk.gray(' Nueva : ') + chalk.bold.white(latest) + '\n\n' +
29
- chalk.white(' npm install -g novatec-cli'),
30
- { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'yellow', dimBorder: true }
31
- )
32
- );
16
+ console.log(boxen(
17
+ chalk.yellow(' Actualización disponible\n\n') +
18
+ chalk.gray(' ' + pkg.version + ' → ') + chalk.bold.white(latest) + '\n\n' +
19
+ chalk.white(' npm install -g novatec-cli'),
20
+ { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'yellow', dimBorder: true }
21
+ ));
33
22
  }
34
- } catch { /* sin internet o error, ignorar */ }
23
+ } catch { /* sin internet, ignorar */ }
35
24
  }
36
25
 
37
26
  async function showBanner() {
38
27
  console.clear();
39
-
40
28
  const logo = figlet.textSync('NOVATEC', { font: 'Slant' });
41
- const lines = logo.split('\n').filter(l => l.trim());
42
-
43
29
  console.log();
44
- lines.forEach(line => console.log(' ' + chalk.bold.white(line)));
30
+ logo.split('\n').filter(l => l.trim()).forEach(l => console.log(' ' + chalk.bold.white(l)));
45
31
  console.log();
46
-
47
- // Versión + fecha
48
- const now = new Date();
49
- const fecha = now.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
50
- console.log(
51
- ' ' + chalk.gray('v' + pkg.version) +
52
- chalk.gray(' · ') +
53
- chalk.gray('NOVATEC FULLSTACK CLI') +
54
- chalk.gray(' · ') +
55
- chalk.gray(fecha)
56
- );
32
+ const now = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
33
+ console.log(' ' + chalk.gray(`v${pkg.version} · Enterprise Fullstack CLI · ${now}`));
57
34
  console.log();
58
35
  console.log(' ' + chalk.white('─'.repeat(60)));
59
36
  console.log();
60
-
61
- // Stats
62
- console.log(
63
- ' ' +
64
- chalk.bold.white('10') + chalk.gray(' frontends') +
65
- chalk.gray(' · ') +
66
- chalk.bold.white('10') + chalk.gray(' backends') +
67
- chalk.gray(' · ') +
68
- chalk.bold.white('TypeScript') + chalk.gray(' ready') +
69
- chalk.gray(' · ') +
70
- chalk.bold.white('Docker') + chalk.gray(' ready')
71
- );
37
+ console.log(boxen(
38
+ chalk.bold.white(' STACK DISPONIBLE\n\n') +
39
+ chalk.white.bold(' Frontend ') + chalk.gray(' │ ') + ['React','Next.js','Vue','Nuxt','Astro','Svelte','SolidJS','Angular','Remix','Qwik'].join(chalk.gray(' · ')) + '\n\n' +
40
+ chalk.white.bold(' Backend ') + chalk.gray(' │ ') + ['Express','NestJS','Fastify','Hono','FastAPI','Django','Flask','Spring','Deno','Go/Gin'].join(chalk.gray(' · ')) + '\n\n' +
41
+ chalk.white.bold(' Database ') + chalk.gray(' ') + ['PostgreSQL','MySQL','MongoDB','SQLite','Supabase'].join(chalk.gray(' · ')) + '\n\n' +
42
+ chalk.white.bold(' Modos ') + chalk.gray(' ') + chalk.white('--mode business') + chalk.gray(' (landing + auth + dashboard + CRUD)') + '\n\n' +
43
+ chalk.white.bold(' Comandos ') + chalk.gray(' ') +
44
+ [chalk.white('create'), chalk.white('add'), chalk.white('doctor'), chalk.white('build'), chalk.white('deploy'), chalk.white('list'), chalk.white('update')]
45
+ .join(chalk.gray(' · ')),
46
+ { padding: { top: 1, bottom: 1, left: 2, right: 4 }, margin: { left: 2 }, borderStyle: 'round', borderColor: 'white', dimBorder: true }
47
+ ));
72
48
  console.log();
73
-
74
- // Caja tecnologías
75
- console.log(
76
- boxen(
77
- chalk.bold.white(' TECNOLOGÍAS\n\n') +
78
- chalk.white.bold(' Frontend') + chalk.gray(' │ ') +
79
- ['React', 'Next.js', 'Vue', 'Nuxt', 'Astro', 'Svelte', 'SolidJS', 'Angular', 'Remix', 'Qwik']
80
- .map(f => chalk.white(f)).join(chalk.gray(' · ')) + '\n\n' +
81
- chalk.white.bold(' Backend ') + chalk.gray(' │ ') +
82
- ['Express', 'NestJS', 'Fastify', 'Hono', 'FastAPI', 'Django', 'Flask', 'Spring', 'Deno', 'Go/Gin']
83
- .map(b => chalk.white(b)).join(chalk.gray(' · ')) + '\n\n' +
84
- chalk.white.bold(' Database') + chalk.gray(' │ ') +
85
- ['PostgreSQL', 'MySQL', 'MongoDB', 'SQLite']
86
- .map(d => chalk.white(d)).join(chalk.gray(' · ')) + '\n\n' +
87
- chalk.white.bold(' Extras ') + chalk.gray(' │ ') +
88
- ['Docker', 'JWT Auth', 'ESLint', 'Husky', 'Vitest', 'GitHub Actions', 'Tailwind']
89
- .map(e => chalk.gray(e)).join(chalk.gray(' · ')),
90
- {
91
- padding: { top: 1, bottom: 1, left: 2, right: 4 },
92
- margin: { left: 2 },
93
- borderStyle: 'round',
94
- borderColor: 'white',
95
- dimBorder: true,
96
- }
97
- )
98
- );
99
-
100
- // Uso rápido
101
- console.log(
102
- boxen(
103
- chalk.gray(' Uso rápido\n\n') +
104
- chalk.white(' $ novatec create ') + chalk.gray('<nombre>') + '\n' +
105
- chalk.white(' $ novatec create my-app ') + chalk.gray('--frontend react --backend express --yes') + '\n' +
106
- chalk.white(' $ novatec list') + chalk.gray(' # ver frameworks') + '\n' +
107
- chalk.white(' $ novatec update') + chalk.gray(' # actualizar CLI'),
108
- {
109
- padding: { top: 0, bottom: 0, left: 1, right: 4 },
110
- margin: { left: 2 },
111
- borderStyle: 'single',
112
- borderColor: 'gray',
113
- dimBorder: true,
114
- }
115
- )
116
- );
117
-
49
+ console.log(boxen(
50
+ chalk.gray(' $ novatec create my-app\n') +
51
+ chalk.gray(' $ novatec create my-app --mode business --frontend next --backend express\n') +
52
+ chalk.gray(' $ novatec add crud Product\n') +
53
+ chalk.gray(' $ novatec doctor'),
54
+ { padding: { top: 0, bottom: 0, left: 1, right: 4 }, margin: { left: 2 }, borderStyle: 'single', borderColor: 'gray', dimBorder: true }
55
+ ));
118
56
  console.log();
119
57
  console.log(' ' + chalk.white('─'.repeat(60)));
120
58
  console.log();
121
59
  }
122
60
 
123
- // ── COMANDOS ──────────────────────────────────────────────────────────────────
124
-
125
61
  program
126
62
  .name('novatec')
127
- .description('NOVATEC FULLSTACK CLI — Generador profesional de proyectos full stack')
63
+ .description('NovaTec CLI — Enterprise Fullstack Generator')
128
64
  .version(pkg.version, '-v, --version', 'Mostrar versión');
129
65
 
130
- // CREATE
66
+ // ── CREATE ────────────────────────────────────────────────────────────────────
131
67
  program
132
68
  .command('create [name]')
133
69
  .description('Crear un nuevo proyecto full stack')
134
- .option('--frontend <type>', 'Framework frontend')
135
- .option('--backend <type>', 'Framework backend')
136
- .option('--db <type>', 'Base de datos: postgres | mysql | mongo | sqlite')
137
- .option('--typescript', 'Usar TypeScript en el proyecto')
138
- .option('--yes', 'Saltar prompts y usar valores por defecto')
139
- .option('--preset <name>', 'Usar preset guardado')
70
+ .option('--frontend <type>', 'Framework frontend')
71
+ .option('--backend <type>', 'Framework backend')
72
+ .option('--db <type>', 'Base de datos: postgres|mysql|mongo|sqlite|supabase')
73
+ .option('--mode <mode>', 'Modo: business (landing+auth+dashboard+CRUD)')
74
+ .option('--architecture <arch>', 'Arquitectura: mvc|hexagonal|clean')
75
+ .option('--package-manager <pm>', 'Package manager: npm|pnpm|yarn')
76
+ .option('--typescript', 'Usar TypeScript')
77
+ .option('--install', 'Instalar dependencias automáticamente')
78
+ .option('--git', 'Inicializar git automáticamente')
79
+ .option('--yes', 'Saltar prompts, usar defaults')
80
+ .option('--preset <name>', 'Preset: ecommerce|reservas|aula-virtual|empresa-web|react-express|next-nestjs')
140
81
  .action(async (name, options) => {
141
82
  await showBanner();
142
83
  await checkForUpdates();
143
84
  try {
85
+ const { checkRequirements } = await import('../lib/utils.js');
86
+ const { runCreate } = await import('../lib/create.js');
144
87
  await checkRequirements();
145
88
  await runCreate(name, options);
146
89
  } catch (err) {
147
- console.error(
148
- boxen(
149
- chalk.red.bold('✖ ERROR\n\n') + chalk.white(err.message),
150
- { padding: 1, borderColor: 'red', borderStyle: 'round' }
151
- )
152
- );
90
+ console.error(boxen(chalk.red.bold('✖ ERROR\n\n') + chalk.white(err.message), { padding: 1, borderColor: 'red', borderStyle: 'round' }));
153
91
  process.exit(1);
154
92
  }
155
93
  });
156
94
 
157
- // LIST
95
+ // ── DOCTOR ────────────────────────────────────────────────────────────────────
158
96
  program
159
- .command('list')
160
- .description('Ver todos los frameworks y tecnologías disponibles')
161
- .action(() => {
162
- console.log();
163
- console.log(
164
- boxen(
165
- chalk.bold.white(' FRAMEWORKS DISPONIBLES\n\n') +
97
+ .command('doctor')
98
+ .description('Verificar entorno de desarrollo')
99
+ .action(async () => {
100
+ const { runDoctor } = await import('../lib/doctor.js');
101
+ await runDoctor();
102
+ });
166
103
 
167
- chalk.bold.white(' Frontend\n') +
168
- [
169
- ' react — React 18 + Vite',
170
- ' next — Next.js 14 (App Router)',
171
- ' vue — Vue 3 + Vite',
172
- ' nuxt Nuxt 3',
173
- ' astro — Astro 4',
174
- ' svelte SvelteKit',
175
- ' solid — SolidJS + Vite',
176
- ' angular — Angular 17',
177
- ' remix — Remix 2',
178
- ' qwik — Qwik City',
179
- ].map(l => chalk.gray(l)).join('\n') + '\n\n' +
104
+ // ── ADD ───────────────────────────────────────────────────────────────────────
105
+ program
106
+ .command('add <module> [name]')
107
+ .description('Agregar módulo al proyecto: auth | dashboard | crud <nombre> | notifications')
108
+ .option('--backend <type>', 'Backend target')
109
+ .option('--db <type>', 'Base de datos target')
110
+ .action(async (module, name, options) => {
111
+ const { runAdd } = await import('../lib/add.js');
112
+ await runAdd(module, name, options);
113
+ });
180
114
 
181
- chalk.bold.white(' Backend\n') +
182
- [
183
- ' express — Express.js (Node)',
184
- ' nestjs — NestJS (Node + TypeScript)',
185
- ' fastify — Fastify (Node)',
186
- ' hono — Hono (Edge-ready)',
187
- ' fastapi — FastAPI (Python)',
188
- ' django Django (Python)',
189
- ' flask — Flask (Python)',
190
- ' spring — Spring Boot (Java)',
191
- ' deno — Deno + Oak',
192
- ' gin — Go + Gin',
193
- ].map(l => chalk.gray(l)).join('\n') + '\n\n' +
115
+ // ── BUILD ─────────────────────────────────────────────────────────────────────
116
+ program
117
+ .command('build')
118
+ .description('Build de producción del proyecto')
119
+ .option('--frontend', 'Solo frontend')
120
+ .option('--backend', 'Solo backend')
121
+ .action(async (options) => {
122
+ const { runBuild } = await import('../lib/generators/deploy.js');
123
+ await runBuild(options);
124
+ });
194
125
 
195
- chalk.bold.white(' Bases de datos\n') +
196
- [
197
- ' postgres — PostgreSQL',
198
- ' mysql — MySQL',
199
- ' mongo — MongoDB',
200
- ' sqlite — SQLite',
201
- ].map(l => chalk.gray(l)).join('\n'),
202
- {
203
- padding: { top: 1, bottom: 1, left: 2, right: 6 },
204
- margin: { left: 2 },
205
- borderStyle: 'round',
206
- borderColor: 'white',
207
- dimBorder: true,
208
- }
209
- )
210
- );
211
- console.log();
126
+ // ── DEPLOY ────────────────────────────────────────────────────────────────────
127
+ program
128
+ .command('deploy')
129
+ .description('Deploy a Vercel (frontend) y Railway (backend)')
130
+ .option('--frontend', 'Solo frontend a Vercel')
131
+ .option('--backend', 'Solo backend a Railway')
132
+ .action(async (options) => {
133
+ const { runDeploy } = await import('../lib/generators/deploy.js');
134
+ await runDeploy(options);
212
135
  });
213
136
 
214
- // UPDATE
137
+ // ── LIST ──────────────────────────────────────────────────────────────────────
138
+ program
139
+ .command('list')
140
+ .description('Ver todos los frameworks y presets disponibles')
141
+ .action(() => {
142
+ console.log(boxen(
143
+ chalk.bold.white(' FRAMEWORKS\n\n') +
144
+ chalk.bold.white(' Frontend\n') +
145
+ ['react','next','vue','nuxt','astro','svelte','solid','angular','remix','qwik'].map(f => chalk.gray(' ' + f)).join('\n') + '\n\n' +
146
+ chalk.bold.white(' Backend\n') +
147
+ ['express','nestjs','fastify','hono','fastapi','django','flask','spring','deno','gin'].map(b => chalk.gray(' ' + b)).join('\n') + '\n\n' +
148
+ chalk.bold.white(' Bases de datos\n') +
149
+ ['postgres','mysql','mongo','sqlite','supabase'].map(d => chalk.gray(' ' + d)).join('\n') + '\n\n' +
150
+ chalk.bold.white(' Presets de negocio\n') +
151
+ ['ecommerce','reservas','aula-virtual','empresa-web','react-express','next-nestjs','vue-fastapi'].map(p => chalk.gray(' ' + p)).join('\n') + '\n\n' +
152
+ chalk.bold.white(' Arquitecturas\n') +
153
+ ['mvc','hexagonal','clean'].map(a => chalk.gray(' ' + a)).join('\n'),
154
+ { padding: { top: 1, bottom: 1, left: 2, right: 6 }, margin: { left: 2 }, borderStyle: 'round', borderColor: 'white', dimBorder: true }
155
+ ));
156
+ });
157
+
158
+ // ── UPDATE ────────────────────────────────────────────────────────────────────
215
159
  program
216
160
  .command('update')
217
161
  .description('Actualizar novatec-cli a la última versión')
218
162
  .action(async () => {
219
163
  const ora = (await import('ora')).default;
220
- const spinner = ora({ text: chalk.white('Actualizando novatec-cli...'), spinner: 'dots', color: 'white' }).start();
164
+ const spinner = ora({ text: chalk.white('Actualizando...'), spinner: 'dots', color: 'white' }).start();
221
165
  try {
222
- execSync('npm install -g novatec-cli@latest', { stdio: 'pipe', encoding: 'utf8' });
223
- const newVer = execSync('npm show novatec-cli version', { encoding: 'utf8' }).trim();
224
- spinner.succeed(chalk.white('Actualizado a v' + newVer));
166
+ execSync('npm install -g novatec-cli@latest', { stdio: 'pipe' });
167
+ const v = execSync('npm show novatec-cli version', { encoding: 'utf8' }).trim();
168
+ spinner.succeed(chalk.white('Actualizado a v' + v));
225
169
  } catch (e) {
226
- spinner.fail(chalk.red('Error al actualizar: ' + e.message));
170
+ spinner.fail(chalk.red('Error: ' + e.message));
227
171
  }
228
172
  });
229
173
 
230
174
  if (!process.argv.slice(2).length) {
231
- (async () => {
232
- await showBanner();
233
- await checkForUpdates();
234
- program.outputHelp();
235
- })();
175
+ (async () => { await showBanner(); await checkForUpdates(); program.outputHelp(); })();
236
176
  } else {
237
177
  program.parse(process.argv);
238
178
  }
package/lib/add.js ADDED
@@ -0,0 +1,122 @@
1
+ import path from 'path';
2
+ import chalk from 'chalk';
3
+ import fse from 'fs-extra';
4
+ import boxen from 'boxen';
5
+
6
+ export async function runAdd(module, name, options) {
7
+ const root = process.cwd();
8
+
9
+ // Detectar si estamos dentro de un proyecto novatec
10
+ const hasFrontend = await fse.pathExists(path.join(root, 'frontend'));
11
+ const hasBackend = await fse.pathExists(path.join(root, 'backend'));
12
+
13
+ if (!hasFrontend && !hasBackend) {
14
+ console.log(boxen(
15
+ chalk.yellow(' Ejecuta este comando dentro de un proyecto NovaTec\n\n') +
16
+ chalk.gray(' cd mi-proyecto\n') +
17
+ chalk.gray(' novatec add ' + module + (name ? ' ' + name : '')),
18
+ { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'yellow', dimBorder: true }
19
+ ));
20
+ process.exit(1);
21
+ }
22
+
23
+ console.log();
24
+ switch (module) {
25
+ case 'auth': await addAuth(root, options); break;
26
+ case 'dashboard': await addDashboard(root, options); break;
27
+ case 'crud': await addCrud(root, name, options); break;
28
+ case 'notifications': await addNotifications(root, options); break;
29
+ default:
30
+ console.log(chalk.red(` Módulo "${module}" no reconocido.`));
31
+ console.log(chalk.gray(' Módulos disponibles: auth | dashboard | crud <nombre> | notifications'));
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ // ── AUTH ──────────────────────────────────────────────────────────────────────
37
+ async function addAuth(root, options) {
38
+ console.log(chalk.white(' Agregando módulo Auth...\n'));
39
+
40
+ // Backend: rutas + middleware + controlador
41
+ const beAuth = path.join(root, 'backend', 'src', 'auth');
42
+ await fse.ensureDir(beAuth);
43
+
44
+ await fse.writeFile(path.join(beAuth, 'auth.middleware.js'), `import jwt from 'jsonwebtoken';\n\nexport const authMiddleware = (req, res, next) => {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token) return res.status(401).json({ error: 'Token requerido' });\n try {\n req.user = jwt.verify(token, process.env.JWT_SECRET || 'change_me');\n next();\n } catch { res.status(401).json({ error: 'Token inválido' }); }\n};\n\nexport const adminMiddleware = (req, res, next) => {\n if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Acceso denegado' });\n next();\n};\n`);
45
+
46
+ await fse.writeFile(path.join(beAuth, 'auth.controller.js'), `import jwt from 'jsonwebtoken';\nimport bcrypt from 'bcryptjs';\n\nconst users = []; // TODO: reemplazar con BD real\n\nexport const register = async (req, res) => {\n const { email, password, name, role = 'user' } = req.body;\n if (!email || !password) return res.status(400).json({ error: 'Email y password requeridos' });\n if (users.find(u => u.email === email)) return res.status(409).json({ error: 'Email ya registrado' });\n const hash = await bcrypt.hash(password, 10);\n const user = { id: Date.now(), email, name, role, password: hash };\n users.push(user);\n const token = jwt.sign({ id: user.id, email, role }, process.env.JWT_SECRET || 'change_me', { expiresIn: '7d' });\n res.status(201).json({ token, user: { id: user.id, email, name, role } });\n};\n\nexport const login = async (req, res) => {\n const { email, password } = req.body;\n const user = users.find(u => u.email === email);\n if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: 'Credenciales inválidas' });\n const token = jwt.sign({ id: user.id, email, role: user.role }, process.env.JWT_SECRET || 'change_me', { expiresIn: '7d' });\n res.json({ token, user: { id: user.id, email, name: user.name, role: user.role } });\n};\n\nexport const me = (req, res) => res.json({ user: req.user });\n\nexport const forgotPassword = async (req, res) => {\n // TODO: implementar envío de email\n res.json({ message: 'Si el email existe, recibirás instrucciones' });\n};\n`);
47
+
48
+ await fse.writeFile(path.join(beAuth, 'auth.routes.js'), `import { Router } from 'express';\nimport { register, login, me, forgotPassword } from './auth.controller.js';\nimport { authMiddleware } from './auth.middleware.js';\n\nconst router = Router();\nrouter.post('/register', register);\nrouter.post('/login', login);\nrouter.get('/me', authMiddleware, me);\nrouter.post('/forgot-password', forgotPassword);\nexport default router;\n`);
49
+
50
+ // Frontend: páginas de login y registro
51
+ const fePages = path.join(root, 'frontend', 'src', 'pages', 'auth');
52
+ await fse.ensureDir(fePages);
53
+
54
+ await fse.writeFile(path.join(fePages, 'Login.jsx'), `import { useState } from 'react';\nimport { api } from '../../lib/api.js';\n\nexport default function Login() {\n const [form, setForm] = useState({ email: '', password: '' });\n const [error, setError] = useState('');\n\n const handleSubmit = async (e) => {\n e.preventDefault();\n try {\n const { token, user } = await api.post('/api/auth/login', form);\n localStorage.setItem('token', token);\n localStorage.setItem('user', JSON.stringify(user));\n window.location.href = '/dashboard';\n } catch { setError('Credenciales inválidas'); }\n };\n\n return (\n <div className="min-h-screen flex items-center justify-center bg-gray-950">\n <div className="w-full max-w-md p-8 bg-gray-900 rounded-2xl border border-gray-800">\n <h1 className="text-2xl font-bold text-white mb-6">Iniciar sesión</h1>\n {error && <p className="text-red-400 mb-4 text-sm">{error}</p>}\n <form onSubmit={handleSubmit} className="space-y-4">\n <input type="email" placeholder="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-white" required />\n <input type="password" placeholder="Contraseña" value={form.password} onChange={e => setForm({...form, password: e.target.value})} className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-white" required />\n <button type="submit" className="w-full py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition">Entrar</button>\n </form>\n <p className="text-gray-500 text-sm mt-4 text-center">¿No tienes cuenta? <a href="/register" className="text-white hover:underline">Regístrate</a></p>\n </div>\n </div>\n );\n}\n`);
55
+
56
+ await fse.writeFile(path.join(fePages, 'Register.jsx'), `import { useState } from 'react';\nimport { api } from '../../lib/api.js';\n\nexport default function Register() {\n const [form, setForm] = useState({ name: '', email: '', password: '' });\n const [error, setError] = useState('');\n\n const handleSubmit = async (e) => {\n e.preventDefault();\n try {\n const { token, user } = await api.post('/api/auth/register', form);\n localStorage.setItem('token', token);\n localStorage.setItem('user', JSON.stringify(user));\n window.location.href = '/dashboard';\n } catch (err) { setError('Error al registrar'); }\n };\n\n return (\n <div className="min-h-screen flex items-center justify-center bg-gray-950">\n <div className="w-full max-w-md p-8 bg-gray-900 rounded-2xl border border-gray-800">\n <h1 className="text-2xl font-bold text-white mb-6">Crear cuenta</h1>\n {error && <p className="text-red-400 mb-4 text-sm">{error}</p>}\n <form onSubmit={handleSubmit} className="space-y-4">\n <input type="text" placeholder="Nombre" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-white" required />\n <input type="email" placeholder="Email" value={form.email} onChange={e => setForm({...form, email: e.target.value})} className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-white" required />\n <input type="password" placeholder="Contraseña" value={form.password} onChange={e => setForm({...form, password: e.target.value})} className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-white" required />\n <button type="submit" className="w-full py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition">Registrarse</button>\n </form>\n <p className="text-gray-500 text-sm mt-4 text-center">¿Ya tienes cuenta? <a href="/login" className="text-white hover:underline">Inicia sesión</a></p>\n </div>\n </div>\n );\n}\n`);
57
+
58
+ done('Auth', ['backend/src/auth/auth.controller.js', 'backend/src/auth/auth.routes.js', 'backend/src/auth/auth.middleware.js', 'frontend/src/pages/auth/Login.jsx', 'frontend/src/pages/auth/Register.jsx']);
59
+ }
60
+
61
+ // ── DASHBOARD ─────────────────────────────────────────────────────────────────
62
+ async function addDashboard(root, options) {
63
+ console.log(chalk.white(' Agregando módulo Dashboard...\n'));
64
+ const dir = path.join(root, 'frontend', 'src', 'pages');
65
+ await fse.ensureDir(dir);
66
+
67
+ await fse.writeFile(path.join(dir, 'Dashboard.jsx'), `import { useState, useEffect } from 'react';\nimport { api } from '../lib/api.js';\n\nconst StatCard = ({ title, value, change }) => (\n <div className="bg-gray-900 border border-gray-800 rounded-xl p-6">\n <p className="text-gray-400 text-sm">{title}</p>\n <p className="text-3xl font-bold text-white mt-1">{value}</p>\n {change && <p className="text-green-400 text-sm mt-1">{change}</p>}\n </div>\n);\n\nexport default function Dashboard() {\n const user = JSON.parse(localStorage.getItem('user') || '{}');\n const [stats] = useState({ users: 128, revenue: '$12,400', orders: 54, growth: '+12%' });\n\n return (\n <div className="min-h-screen bg-gray-950 text-white">\n {/* Sidebar */}\n <aside className="fixed left-0 top-0 h-full w-64 bg-gray-900 border-r border-gray-800 p-6">\n <div className="mb-8">\n <h2 className="text-xl font-bold text-white">NovaTec</h2>\n <p className="text-gray-500 text-sm">Panel administrativo</p>\n </div>\n <nav className="space-y-1">\n {[['Dashboard', '/dashboard'], ['Usuarios', '/dashboard/users'], ['Configuración', '/dashboard/settings']].map(([label, href]) => (\n <a key={href} href={href} className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition text-sm">{label}</a>\n ))}\n </nav>\n <div className="absolute bottom-6 left-6 right-6">\n <button onClick={() => { localStorage.clear(); window.location.href = '/login'; }} className="w-full text-left text-gray-500 hover:text-white text-sm transition">Cerrar sesión</button>\n </div>\n </aside>\n {/* Main */}\n <main className="ml-64 p-8">\n <div className="mb-8">\n <h1 className="text-2xl font-bold">Bienvenido, {user.name || 'Admin'}</h1>\n <p className="text-gray-400 text-sm mt-1">Resumen del sistema</p>\n </div>\n <div className="grid grid-cols-4 gap-4 mb-8">\n <StatCard title="Usuarios" value={stats.users} change="+8 este mes" />\n <StatCard title="Ingresos" value={stats.revenue} change="+12% vs mes anterior" />\n <StatCard title="Pedidos" value={stats.orders} change="+5 hoy" />\n <StatCard title="Crecimiento" value={stats.growth} />\n </div>\n <div className="bg-gray-900 border border-gray-800 rounded-xl p-6">\n <h3 className="text-white font-semibold mb-4">Actividad reciente</h3>\n <p className="text-gray-500 text-sm">No hay actividad reciente.</p>\n </div>\n </main>\n </div>\n );\n}\n`);
68
+
69
+ done('Dashboard', ['frontend/src/pages/Dashboard.jsx']);
70
+ }
71
+
72
+ // ── CRUD ──────────────────────────────────────────────────────────────────────
73
+ async function addCrud(root, entityName, options) {
74
+ if (!entityName) {
75
+ console.log(chalk.red(' Especifica el nombre: novatec add crud <Nombre>'));
76
+ process.exit(1);
77
+ }
78
+ const name = entityName.charAt(0).toUpperCase() + entityName.slice(1);
79
+ const nameLower = entityName.toLowerCase();
80
+ console.log(chalk.white(` Generando CRUD para "${name}"...\n`));
81
+
82
+ // Backend
83
+ const beDir = path.join(root, 'backend', 'src', nameLower);
84
+ await fse.ensureDir(beDir);
85
+
86
+ await fse.writeFile(path.join(beDir, `${nameLower}.model.js`), `// Modelo ${name}\n// TODO: conectar con tu ORM (Prisma, Mongoose, etc.)\nconst items = [];\nlet nextId = 1;\n\nexport const ${name}Model = {\n findAll: () => items,\n findById: (id) => items.find(i => i.id === parseInt(id)),\n create: (data) => { const item = { id: nextId++, ...data, createdAt: new Date() }; items.push(item); return item; },\n update: (id, data) => { const i = items.findIndex(x => x.id === parseInt(id)); if (i < 0) return null; items[i] = { ...items[i], ...data }; return items[i]; },\n delete: (id) => { const i = items.findIndex(x => x.id === parseInt(id)); if (i < 0) return false; items.splice(i, 1); return true; },\n};\n`);
87
+
88
+ await fse.writeFile(path.join(beDir, `${nameLower}.controller.js`), `import { ${name}Model } from './${nameLower}.model.js';\n\nexport const getAll = (req, res) => res.json(${name}Model.findAll());\nexport const getOne = (req, res) => { const item = ${name}Model.findById(req.params.id); item ? res.json(item) : res.status(404).json({ error: 'No encontrado' }); };\nexport const create = (req, res) => { const item = ${name}Model.create(req.body); res.status(201).json(item); };\nexport const update = (req, res) => { const item = ${name}Model.update(req.params.id, req.body); item ? res.json(item) : res.status(404).json({ error: 'No encontrado' }); };\nexport const remove = (req, res) => { ${name}Model.delete(req.params.id) ? res.json({ message: 'Eliminado' }) : res.status(404).json({ error: 'No encontrado' }); };\n`);
89
+
90
+ await fse.writeFile(path.join(beDir, `${nameLower}.routes.js`), `import { Router } from 'express';\nimport { getAll, getOne, create, update, remove } from './${nameLower}.controller.js';\n\nconst router = Router();\nrouter.get('/', getAll);\nrouter.get('/:id', getOne);\nrouter.post('/', create);\nrouter.put('/:id', update);\nrouter.delete('/:id', remove);\nexport default router;\n`);
91
+
92
+ // Frontend: tabla + formulario
93
+ const feDir = path.join(root, 'frontend', 'src', 'pages', nameLower);
94
+ await fse.ensureDir(feDir);
95
+
96
+ await fse.writeFile(path.join(feDir, `${name}List.jsx`), `import { useState, useEffect } from 'react';\nimport { api } from '../../lib/api.js';\n\nexport default function ${name}List() {\n const [items, setItems] = useState([]);\n const [form, setForm] = useState({ name: '', description: '' });\n const [editing, setEditing] = useState(null);\n\n const load = async () => setItems(await api.get('/api/${nameLower}'));\n useEffect(() => { load(); }, []);\n\n const save = async (e) => {\n e.preventDefault();\n if (editing) { await api.put(\`/api/${nameLower}/\${editing}\`, form); setEditing(null); }\n else { await api.post('/api/${nameLower}', form); }\n setForm({ name: '', description: '' });\n load();\n };\n\n const del = async (id) => { if (confirm('¿Eliminar?')) { await api.delete(\`/api/${nameLower}/\${id}\`); load(); } };\n const edit = (item) => { setEditing(item.id); setForm({ name: item.name, description: item.description }); };\n\n return (\n <div className="p-8 bg-gray-950 min-h-screen text-white">\n <h1 className="text-2xl font-bold mb-6">${name}</h1>\n <form onSubmit={save} className="flex gap-3 mb-8">\n <input value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="Nombre" className="flex-1 px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-white" required />\n <input value={form.description} onChange={e => setForm({...form, description: e.target.value})} placeholder="Descripción" className="flex-1 px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-white" />\n <button type="submit" className="px-6 py-2 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition">{editing ? 'Actualizar' : 'Crear'}</button>\n {editing && <button type="button" onClick={() => { setEditing(null); setForm({ name: '', description: '' }); }} className="px-4 py-2 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition">Cancelar</button>}\n </form>\n <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">\n <table className="w-full">\n <thead><tr className="border-b border-gray-800">\n <th className="text-left px-6 py-3 text-gray-400 text-sm font-medium">ID</th>\n <th className="text-left px-6 py-3 text-gray-400 text-sm font-medium">Nombre</th>\n <th className="text-left px-6 py-3 text-gray-400 text-sm font-medium">Descripción</th>\n <th className="text-left px-6 py-3 text-gray-400 text-sm font-medium">Acciones</th>\n </tr></thead>\n <tbody>{items.map(item => (\n <tr key={item.id} className="border-b border-gray-800 hover:bg-gray-800/50 transition">\n <td className="px-6 py-4 text-gray-400 text-sm">{item.id}</td>\n <td className="px-6 py-4 text-white">{item.name}</td>\n <td className="px-6 py-4 text-gray-400">{item.description}</td>\n <td className="px-6 py-4 flex gap-2">\n <button onClick={() => edit(item)} className="text-sm text-gray-400 hover:text-white transition">Editar</button>\n <button onClick={() => del(item.id)} className="text-sm text-red-400 hover:text-red-300 transition">Eliminar</button>\n </td>\n </tr>\n ))}</tbody>\n </table>\n {items.length === 0 && <p className="text-center text-gray-500 py-8 text-sm">No hay registros</p>}\n </div>\n </div>\n );\n}\n`);
97
+
98
+ done(`CRUD ${name}`, [`backend/src/${nameLower}/${nameLower}.model.js`, `backend/src/${nameLower}/${nameLower}.controller.js`, `backend/src/${nameLower}/${nameLower}.routes.js`, `frontend/src/pages/${nameLower}/${name}List.jsx`]);
99
+ }
100
+
101
+ // ── NOTIFICATIONS ─────────────────────────────────────────────────────────────
102
+ async function addNotifications(root, options) {
103
+ console.log(chalk.white(' Agregando sistema de notificaciones...\n'));
104
+ const dir = path.join(root, 'frontend', 'src', 'components');
105
+ await fse.ensureDir(dir);
106
+
107
+ await fse.writeFile(path.join(dir, 'Toast.jsx'), `import { useState, useEffect, createContext, useContext, useCallback } from 'react';\n\nconst ToastCtx = createContext(null);\n\nexport function ToastProvider({ children }) {\n const [toasts, setToasts] = useState([]);\n const add = useCallback((message, type = 'info', duration = 4000) => {\n const id = Date.now();\n setToasts(t => [...t, { id, message, type }]);\n setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), duration);\n }, []);\n const colors = { success: 'bg-green-900 border-green-700 text-green-200', error: 'bg-red-900 border-red-700 text-red-200', warning: 'bg-yellow-900 border-yellow-700 text-yellow-200', info: 'bg-gray-900 border-gray-700 text-gray-200' };\n return (\n <ToastCtx.Provider value={add}>\n {children}\n <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">\n {toasts.map(t => (\n <div key={t.id} className={\`px-4 py-3 rounded-lg border text-sm font-medium shadow-lg animate-in slide-in-from-right \${colors[t.type]}\`}>{t.message}</div>\n ))}\n </div>\n </ToastCtx.Provider>\n );\n}\n\nexport const useToast = () => useContext(ToastCtx);\n`);
108
+
109
+ done('Notifications', ['frontend/src/components/Toast.jsx']);
110
+ console.log(chalk.gray('\n Uso:\n'));
111
+ console.log(chalk.gray(' import { ToastProvider, useToast } from "./components/Toast";\n'));
112
+ console.log(chalk.gray(' const toast = useToast();\n'));
113
+ console.log(chalk.gray(' toast("Guardado!", "success");\n'));
114
+ }
115
+
116
+ function done(module, files) {
117
+ console.log(boxen(
118
+ chalk.bold.white(` ✔ Módulo "${module}" agregado\n\n`) +
119
+ files.map(f => chalk.gray(' ' + f)).join('\n'),
120
+ { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'white', dimBorder: true }
121
+ ));
122
+ }