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 +109 -169
- package/lib/add.js +122 -0
- package/lib/create.js +220 -310
- 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 +159 -188
- package/package.json +1 -1
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
30
|
+
logo.split('\n').filter(l => l.trim()).forEach(l => console.log(' ' + chalk.bold.white(l)));
|
|
45
31
|
console.log();
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
' ' +
|
|
64
|
-
chalk.bold
|
|
65
|
-
chalk.gray('
|
|
66
|
-
chalk.bold
|
|
67
|
-
chalk.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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('
|
|
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>',
|
|
135
|
-
.option('--backend <type>',
|
|
136
|
-
.option('--db <type>',
|
|
137
|
-
.option('--
|
|
138
|
-
.option('--
|
|
139
|
-
.option('--
|
|
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
|
-
//
|
|
95
|
+
// ── DOCTOR ────────────────────────────────────────────────────────────────────
|
|
158
96
|
program
|
|
159
|
-
.command('
|
|
160
|
-
.description('
|
|
161
|
-
.action(() => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
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
|
|
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'
|
|
223
|
-
const
|
|
224
|
-
spinner.succeed(chalk.white('Actualizado a v' +
|
|
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
|
|
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
|
+
}
|