novatec-cli 1.0.2 → 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,156 +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 { checkRequirements } from '../lib/utils.js';
8
- import { runCreate } from '../lib/create.js';
9
-
10
- function sleep(ms) {
11
- return new Promise(r => setTimeout(r, ms));
6
+ import { execSync, spawnSync } from 'child_process';
7
+ import { createRequire } from 'module';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const pkg = require('../package.json');
11
+
12
+ async function checkForUpdates() {
13
+ try {
14
+ const latest = execSync(`npm show ${pkg.name} version`, { encoding: 'utf8', timeout: 3000 }).trim();
15
+ if (latest && latest !== pkg.version) {
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
+ ));
22
+ }
23
+ } catch { /* sin internet, ignorar */ }
12
24
  }
13
25
 
14
26
  async function showBanner() {
15
27
  console.clear();
16
-
17
- // Logo en blanco puro con sombra gris (efecto profundidad)
18
- const logo = figlet.textSync('NOVATEC', { font: 'ANSI Shadow' });
19
- const lines = logo.split('\n').filter(l => l.trim());
20
-
28
+ const logo = figlet.textSync('NOVATEC', { font: 'Slant' });
21
29
  console.log();
22
- lines.forEach(line => {
23
- console.log(' ' + chalk.bold.white(line));
24
- });
25
-
30
+ logo.split('\n').filter(l => l.trim()).forEach(l => console.log(' ' + chalk.bold.white(l)));
26
31
  console.log();
27
-
28
- // Subtítulo elegante
29
- const sub = ' ◈ NOVATEC FULLSTACK CLI · Generador Profesional de Proyectos ◈';
30
- console.log(chalk.gray(sub));
31
- console.log();
32
-
33
- // Separador
34
- console.log(' ' + chalk.white('─'.repeat(66)));
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}`));
35
34
  console.log();
36
-
37
- // Stats rápidos
38
- console.log(
39
- ' ' +
40
- chalk.white.bold('10') + chalk.gray(' frontends ') +
41
- chalk.white('·') +
42
- chalk.white.bold(' 10') + chalk.gray(' backends ') +
43
- chalk.white('·') +
44
- chalk.white.bold(' 100%') + chalk.gray(' listo para producción')
45
- );
35
+ console.log(' ' + chalk.white('─'.repeat(60)));
46
36
  console.log();
47
-
48
- // Caja de tecnologías — blanco y negro elegante
49
- console.log(
50
- boxen(
51
- chalk.bold.white(' TECNOLOGÍAS DISPONIBLES') + '\n\n' +
52
-
53
- chalk.white.bold(' Frontend') + chalk.gray(' │ ') +
54
- [
55
- chalk.white('React'),
56
- chalk.white('Next.js'),
57
- chalk.white('Vue'),
58
- chalk.white('Nuxt'),
59
- chalk.white('Astro'),
60
- chalk.white('Svelte'),
61
- chalk.white('SolidJS'),
62
- chalk.white('Angular'),
63
- chalk.white('Remix'),
64
- chalk.white('Qwik'),
65
- ].join(chalk.gray(' · ')) + '\n\n' +
66
-
67
- chalk.white.bold(' Backend ') + chalk.gray(' │ ') +
68
- [
69
- chalk.white('Express'),
70
- chalk.white('NestJS'),
71
- chalk.white('Fastify'),
72
- chalk.white('Hono'),
73
- chalk.white('FastAPI'),
74
- chalk.white('Django'),
75
- chalk.white('Flask'),
76
- chalk.white('Spring'),
77
- chalk.white('Deno'),
78
- chalk.white('Go/Gin'),
79
- ].join(chalk.gray(' · ')) + '\n\n' +
80
-
81
- chalk.white.bold(' Extras ') + chalk.gray(' │ ') +
82
- [
83
- chalk.gray('Docker'),
84
- chalk.gray('ESLint + Prettier'),
85
- chalk.gray('Husky'),
86
- chalk.gray('Jest / Vitest'),
87
- chalk.gray('GitHub Actions'),
88
- chalk.gray('.env'),
89
- ].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
-
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
+ ));
100
48
  console.log();
101
-
102
- // Uso rápido
103
- console.log(
104
- boxen(
105
- chalk.gray(' Uso rápido\n\n') +
106
- chalk.white(' $ novatec-cli create ') + chalk.gray('<nombre-proyecto>') + '\n\n' +
107
- chalk.white(' $ novatec-cli create my-app ') + chalk.gray('--frontend react --backend express'),
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
- console.log(' ' + chalk.white('─'.repeat(66)));
57
+ console.log(' ' + chalk.white('─'.repeat(60)));
120
58
  console.log();
121
59
  }
122
60
 
123
61
  program
124
62
  .name('novatec')
125
- .description('NOVATEC FULLSTACK CLI — Generador profesional de proyectos full stack')
126
- .version('1.0.2', '-v, --version', 'Mostrar versión');
63
+ .description('NovaTec CLI — Enterprise Fullstack Generator')
64
+ .version(pkg.version, '-v, --version', 'Mostrar versión');
127
65
 
66
+ // ── CREATE ────────────────────────────────────────────────────────────────────
128
67
  program
129
68
  .command('create [name]')
130
69
  .description('Crear un nuevo proyecto full stack')
131
- .option('--frontend <type>', 'react | next | vue | nuxt | astro | svelte | solid | angular | remix | qwik')
132
- .option('--backend <type>', 'express | nestjs | fastify | hono | fastapi | django | flask | spring | deno | gin')
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')
133
81
  .action(async (name, options) => {
134
82
  await showBanner();
83
+ await checkForUpdates();
135
84
  try {
85
+ const { checkRequirements } = await import('../lib/utils.js');
86
+ const { runCreate } = await import('../lib/create.js');
136
87
  await checkRequirements();
137
88
  await runCreate(name, options);
138
89
  } catch (err) {
139
- console.error(
140
- boxen(
141
- chalk.red.bold('✖ ERROR\n\n') + chalk.white(err.message),
142
- { padding: 1, borderColor: 'red', borderStyle: 'round' }
143
- )
144
- );
90
+ console.error(boxen(chalk.red.bold('✖ ERROR\n\n') + chalk.white(err.message), { padding: 1, borderColor: 'red', borderStyle: 'round' }));
145
91
  process.exit(1);
146
92
  }
147
93
  });
148
94
 
95
+ // ── DOCTOR ────────────────────────────────────────────────────────────────────
96
+ program
97
+ .command('doctor')
98
+ .description('Verificar entorno de desarrollo')
99
+ .action(async () => {
100
+ const { runDoctor } = await import('../lib/doctor.js');
101
+ await runDoctor();
102
+ });
103
+
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
+ });
114
+
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
+ });
125
+
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);
135
+ });
136
+
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 ────────────────────────────────────────────────────────────────────
159
+ program
160
+ .command('update')
161
+ .description('Actualizar novatec-cli a la última versión')
162
+ .action(async () => {
163
+ const ora = (await import('ora')).default;
164
+ const spinner = ora({ text: chalk.white('Actualizando...'), spinner: 'dots', color: 'white' }).start();
165
+ try {
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));
169
+ } catch (e) {
170
+ spinner.fail(chalk.red('Error: ' + e.message));
171
+ }
172
+ });
173
+
149
174
  if (!process.argv.slice(2).length) {
150
- (async () => {
151
- await showBanner();
152
- program.outputHelp();
153
- })();
175
+ (async () => { await showBanner(); await checkForUpdates(); program.outputHelp(); })();
154
176
  } else {
155
177
  program.parse(process.argv);
156
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
+ }