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 +140 -118
- package/lib/add.js +122 -0
- package/lib/create.js +248 -237
- package/lib/doctor.js +167 -0
- package/lib/generators/business.js +262 -0
- package/lib/generators/deploy.js +166 -0
- package/lib/generators/docker.js +97 -0
- package/lib/generators/security.js +132 -0
- package/lib/prompts.js +208 -310
- package/package.json +1 -1
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 {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
57
|
+
console.log(' ' + chalk.white('─'.repeat(60)));
|
|
120
58
|
console.log();
|
|
121
59
|
}
|
|
122
60
|
|
|
123
61
|
program
|
|
124
62
|
.name('novatec')
|
|
125
|
-
.description('
|
|
126
|
-
.version(
|
|
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>',
|
|
132
|
-
.option('--backend <type>',
|
|
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
|
+
}
|