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/lib/doctor.js ADDED
@@ -0,0 +1,167 @@
1
+ import chalk from 'chalk';
2
+ import boxen from 'boxen';
3
+ import { spawnSync, execSync } from 'child_process';
4
+
5
+ function check(cmd, args = ['--version']) {
6
+ const r = spawnSync(cmd, args, { stdio: 'pipe', shell: true, encoding: 'utf8' });
7
+ if (r.status !== 0) return null;
8
+ return (r.stdout || r.stderr || '').trim().split('\n')[0];
9
+ }
10
+
11
+ function getVersion(cmd) {
12
+ try { return execSync(`${cmd} --version`, { encoding: 'utf8', stdio: 'pipe' }).trim().split('\n')[0]; } catch { return null; }
13
+ }
14
+
15
+ const CHECKS = [
16
+ {
17
+ name: 'Node.js',
18
+ cmd: 'node',
19
+ required: true,
20
+ minVersion: 18,
21
+ fix: 'Descarga desde https://nodejs.org (versión LTS)',
22
+ getVer: () => getVersion('node'),
23
+ parseVer: v => parseInt(v?.replace('v', '')),
24
+ },
25
+ {
26
+ name: 'npm',
27
+ cmd: 'npm',
28
+ required: true,
29
+ minVersion: 9,
30
+ fix: 'Se instala con Node.js. Actualiza con: npm install -g npm@latest',
31
+ getVer: () => getVersion('npm'),
32
+ parseVer: v => parseInt(v),
33
+ },
34
+ {
35
+ name: 'pnpm',
36
+ cmd: 'pnpm',
37
+ required: false,
38
+ fix: 'npm install -g pnpm',
39
+ getVer: () => getVersion('pnpm'),
40
+ },
41
+ {
42
+ name: 'yarn',
43
+ cmd: 'yarn',
44
+ required: false,
45
+ fix: 'npm install -g yarn',
46
+ getVer: () => getVersion('yarn'),
47
+ },
48
+ {
49
+ name: 'Git',
50
+ cmd: 'git',
51
+ required: true,
52
+ fix: 'Descarga desde https://git-scm.com',
53
+ getVer: () => getVersion('git'),
54
+ },
55
+ {
56
+ name: 'Docker',
57
+ cmd: 'docker',
58
+ required: false,
59
+ fix: 'Descarga Docker Desktop desde https://docker.com',
60
+ getVer: () => getVersion('docker'),
61
+ },
62
+ {
63
+ name: 'Python',
64
+ cmd: 'python',
65
+ required: false,
66
+ fix: 'Descarga desde https://python.org (requerido para FastAPI/Django/Flask)',
67
+ getVer: () => getVersion('python') || getVersion('python3'),
68
+ },
69
+ {
70
+ name: 'pip',
71
+ cmd: 'pip',
72
+ required: false,
73
+ fix: 'Se instala con Python. Actualiza con: python -m pip install --upgrade pip',
74
+ getVer: () => getVersion('pip') || getVersion('pip3'),
75
+ },
76
+ {
77
+ name: 'Java',
78
+ cmd: 'java',
79
+ required: false,
80
+ fix: 'Descarga JDK desde https://adoptium.net (requerido para Spring Boot)',
81
+ getVer: () => { try { return execSync('java -version 2>&1', { encoding: 'utf8' }).trim().split('\n')[0]; } catch { return null; } },
82
+ },
83
+ {
84
+ name: 'Go',
85
+ cmd: 'go',
86
+ required: false,
87
+ fix: 'Descarga desde https://go.dev (requerido para Go/Gin)',
88
+ getVer: () => getVersion('go'),
89
+ },
90
+ {
91
+ name: 'Deno',
92
+ cmd: 'deno',
93
+ required: false,
94
+ fix: 'irm https://deno.land/install.ps1 | iex (PowerShell)',
95
+ getVer: () => getVersion('deno'),
96
+ },
97
+ ];
98
+
99
+ export async function runDoctor() {
100
+ console.log();
101
+ console.log(boxen(
102
+ chalk.bold.white(' NOVATEC DOCTOR — Verificación del entorno'),
103
+ { padding: { top: 0, bottom: 0, left: 1, right: 4 }, margin: { left: 2 }, borderStyle: 'round', borderColor: 'white', dimBorder: true }
104
+ ));
105
+ console.log();
106
+
107
+ const results = [];
108
+ let allRequired = true;
109
+
110
+ for (const tool of CHECKS) {
111
+ const ver = tool.getVer ? tool.getVer() : null;
112
+ const ok = !!ver;
113
+
114
+ // Verificar versión mínima
115
+ let versionOk = true;
116
+ if (ok && tool.minVersion && tool.parseVer) {
117
+ const parsed = tool.parseVer(ver);
118
+ if (parsed < tool.minVersion) versionOk = false;
119
+ }
120
+
121
+ const status = !ok ? 'missing' : !versionOk ? 'outdated' : 'ok';
122
+ if (tool.required && status !== 'ok') allRequired = false;
123
+
124
+ results.push({ ...tool, ver, status });
125
+
126
+ const icon = status === 'ok' ? chalk.white('✔') : status === 'outdated' ? chalk.yellow('⚠') : chalk.gray('✗');
127
+ const label = chalk.white(tool.name.padEnd(12));
128
+ const value = status === 'ok' ? chalk.gray(ver || '') :
129
+ status === 'outdated' ? chalk.yellow(ver + ` (requiere >= ${tool.minVersion})`) :
130
+ chalk.gray(tool.required ? 'no encontrado [requerido]' : 'no instalado [opcional]');
131
+
132
+ console.log(` ${icon} ${label} ${value}`);
133
+ }
134
+
135
+ console.log();
136
+
137
+ // Mostrar soluciones para los que fallan
138
+ const problems = results.filter(r => r.status !== 'ok' && r.required);
139
+ const warnings = results.filter(r => r.status !== 'ok' && !r.required);
140
+
141
+ if (problems.length) {
142
+ console.log(boxen(
143
+ chalk.bold.white(' PROBLEMAS ENCONTRADOS\n\n') +
144
+ problems.map(p =>
145
+ chalk.white(` ${p.name}\n`) +
146
+ chalk.gray(` ${p.status === 'outdated' ? 'Versión desactualizada' : 'No instalado'}\n`) +
147
+ chalk.gray(` Solución: ${p.fix}`)
148
+ ).join('\n\n'),
149
+ { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'red', dimBorder: true }
150
+ ));
151
+ }
152
+
153
+ if (warnings.length) {
154
+ console.log(boxen(
155
+ chalk.bold.white(' OPCIONALES NO INSTALADOS\n\n') +
156
+ warnings.map(w => chalk.gray(` ${w.name.padEnd(10)} → ${w.fix}`)).join('\n'),
157
+ { padding: 1, margin: { left: 2 }, borderStyle: 'single', borderColor: 'gray', dimBorder: true }
158
+ ));
159
+ }
160
+
161
+ if (allRequired) {
162
+ console.log(' ' + chalk.white('✔ Entorno listo para usar NovaTec CLI'));
163
+ } else {
164
+ console.log(' ' + chalk.yellow('⚠ Instala las herramientas requeridas antes de continuar'));
165
+ }
166
+ console.log();
167
+ }
@@ -0,0 +1,262 @@
1
+ import path from 'path';
2
+ import fse from 'fs-extra';
3
+
4
+ export async function generateBusiness(config, root) {
5
+ const fe = path.join(root, 'frontend', 'src');
6
+ const be = path.join(root, 'backend', 'src');
7
+ await generateLanding(config, fe);
8
+ await generateNavbar(fe);
9
+ await generateSidebar(fe);
10
+ await generateDashboard(fe);
11
+ await generateAuthPages(fe);
12
+ await generateRoles(be);
13
+ if (config.extras?.includes('stripe')) await generateStripe(be);
14
+ if (config.extras?.includes('whatsapp')) await generateWhatsApp(fe);
15
+ if (config.extras?.includes('darkmode')) await generateDarkMode(fe);
16
+ }
17
+
18
+ async function generateLanding(config, fe) {
19
+ await fse.ensureDir(path.join(fe, 'pages'));
20
+ const n = config.name || 'Mi Empresa';
21
+ await fse.writeFile(path.join(fe, 'pages', 'Landing.jsx'),
22
+ `export default function Landing() {
23
+ return (
24
+ <div className="bg-gray-950 text-white min-h-screen">
25
+ <nav className="fixed top-0 w-full z-50 bg-gray-950/80 backdrop-blur border-b border-gray-800">
26
+ <div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
27
+ <span className="text-xl font-bold">${n}</span>
28
+ <div className="flex items-center gap-6">
29
+ {['Inicio','Servicios','Proyectos','Contacto'].map(s => (
30
+ <a key={s} href={\`#\${s.toLowerCase()}\`} className="text-gray-400 hover:text-white text-sm transition">{s}</a>
31
+ ))}
32
+ <a href="/login" className="px-4 py-2 bg-white text-black text-sm font-semibold rounded-lg hover:bg-gray-200 transition">Entrar</a>
33
+ </div>
34
+ </div>
35
+ </nav>
36
+
37
+ <section id="inicio" className="min-h-screen flex items-center justify-center text-center px-6 pt-20">
38
+ <div className="max-w-3xl">
39
+ <span className="text-xs text-gray-500 uppercase tracking-widest border border-gray-800 px-3 py-1 rounded-full">Bienvenido</span>
40
+ <h1 className="text-5xl md:text-7xl font-bold mt-6 mb-6 leading-tight">${n}</h1>
41
+ <p className="text-gray-400 text-lg mb-10 max-w-xl mx-auto">Soluciones modernas y elegantes para tu negocio.</p>
42
+ <div className="flex gap-4 justify-center">
43
+ <a href="/register" className="px-8 py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition">Comenzar gratis</a>
44
+ <a href="#servicios" className="px-8 py-3 border border-gray-700 text-white rounded-lg hover:border-gray-500 transition">Ver más</a>
45
+ </div>
46
+ </div>
47
+ </section>
48
+
49
+ <section id="servicios" className="py-24 px-6">
50
+ <div className="max-w-6xl mx-auto">
51
+ <h2 className="text-3xl font-bold text-center mb-4">Servicios</h2>
52
+ <p className="text-gray-400 text-center mb-16">Todo lo que necesitas en un solo lugar</p>
53
+ <div className="grid md:grid-cols-3 gap-6">
54
+ {[{t:'Desarrollo Web',d:'Aplicaciones modernas y escalables'},{t:'Consultoría',d:'Asesoría técnica especializada'},{t:'Soporte 24/7',d:'Atención continua para tu negocio'}].map(s => (
55
+ <div key={s.t} className="bg-gray-900 border border-gray-800 rounded-xl p-8 hover:border-gray-600 transition">
56
+ <h3 className="text-xl font-semibold mb-3">{s.t}</h3>
57
+ <p className="text-gray-400 text-sm">{s.d}</p>
58
+ </div>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ </section>
63
+
64
+ <section id="proyectos" className="py-24 px-6 bg-gray-900/50">
65
+ <div className="max-w-6xl mx-auto">
66
+ <h2 className="text-3xl font-bold text-center mb-16">Proyectos</h2>
67
+ <div className="grid md:grid-cols-2 gap-6">
68
+ {[1,2,3,4].map(i => (
69
+ <div key={i} className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden hover:border-gray-600 transition">
70
+ <div className="h-48 bg-gray-800 flex items-center justify-center text-gray-600 text-sm">Proyecto {i}</div>
71
+ <div className="p-6"><h3 className="font-semibold mb-2">Proyecto {i}</h3><p className="text-gray-400 text-sm">Descripción del proyecto.</p></div>
72
+ </div>
73
+ ))}
74
+ </div>
75
+ </div>
76
+ </section>
77
+
78
+ <section id="contacto" className="py-24 px-6">
79
+ <div className="max-w-xl mx-auto text-center">
80
+ <h2 className="text-3xl font-bold mb-4">Contacto</h2>
81
+ <p className="text-gray-400 mb-10">Hablemos de tu proyecto.</p>
82
+ <form className="space-y-4 text-left">
83
+ <input type="text" placeholder="Nombre" className="w-full px-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white focus:outline-none focus:border-white" />
84
+ <input type="email" placeholder="Email" className="w-full px-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white focus:outline-none focus:border-white" />
85
+ <textarea rows={4} placeholder="Mensaje" className="w-full px-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-white focus:outline-none focus:border-white resize-none" />
86
+ <button type="submit" className="w-full py-3 bg-white text-black font-semibold rounded-lg hover:bg-gray-200 transition">Enviar</button>
87
+ </form>
88
+ </div>
89
+ </section>
90
+
91
+ <footer className="border-t border-gray-800 py-8 text-center text-gray-500 text-sm">
92
+ © {new Date().getFullYear()} ${n}. Todos los derechos reservados.
93
+ </footer>
94
+ </div>
95
+ );
96
+ }
97
+ `);
98
+ }
99
+
100
+ async function generateNavbar(fe) {
101
+ await fse.ensureDir(path.join(fe, 'components'));
102
+ await fse.writeFile(path.join(fe, 'components', 'Navbar.jsx'),
103
+ `export default function Navbar({ title = 'NovaTec' }) {
104
+ return (
105
+ <nav className="fixed top-0 w-full z-50 bg-gray-950/80 backdrop-blur border-b border-gray-800">
106
+ <div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
107
+ <span className="text-xl font-bold text-white">{title}</span>
108
+ <div className="flex items-center gap-4">
109
+ <a href="/dashboard" className="text-gray-400 hover:text-white text-sm transition">Dashboard</a>
110
+ <button onClick={() => { localStorage.clear(); window.location.href = '/login'; }} className="text-gray-400 hover:text-white text-sm transition">Salir</button>
111
+ </div>
112
+ </div>
113
+ </nav>
114
+ );
115
+ }
116
+ `);
117
+ }
118
+
119
+ async function generateSidebar(fe) {
120
+ await fse.ensureDir(path.join(fe, 'components'));
121
+ await fse.writeFile(path.join(fe, 'components', 'Sidebar.jsx'),
122
+ `const links = [
123
+ { label: 'Dashboard', href: '/dashboard' },
124
+ { label: 'Usuarios', href: '/dashboard/users' },
125
+ { label: 'Reportes', href: '/dashboard/reports' },
126
+ { label: 'Configuración', href: '/dashboard/settings' },
127
+ ];
128
+
129
+ export default function Sidebar() {
130
+ const current = window.location.pathname;
131
+ return (
132
+ <aside className="fixed left-0 top-0 h-full w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
133
+ <div className="p-6 border-b border-gray-800">
134
+ <h2 className="text-lg font-bold text-white">NovaTec</h2>
135
+ <p className="text-gray-500 text-xs mt-1">Panel administrativo</p>
136
+ </div>
137
+ <nav className="flex-1 p-4 space-y-1">
138
+ {links.map(l => (
139
+ <a key={l.href} href={l.href} className={\`flex items-center px-3 py-2 rounded-lg text-sm transition \${current === l.href ? 'bg-white text-black font-medium' : 'text-gray-400 hover:text-white hover:bg-gray-800'}\`}>{l.label}</a>
140
+ ))}
141
+ </nav>
142
+ <div className="p-4 border-t border-gray-800">
143
+ <button onClick={() => { localStorage.clear(); window.location.href = '/login'; }} className="w-full text-left text-gray-500 hover:text-white text-sm transition px-3 py-2">Cerrar sesión</button>
144
+ </div>
145
+ </aside>
146
+ );
147
+ }
148
+ `);
149
+ }
150
+
151
+ async function generateDashboard(fe) {
152
+ await fse.ensureDir(path.join(fe, 'pages'));
153
+ await fse.writeFile(path.join(fe, 'pages', 'Dashboard.jsx'),
154
+ `import Sidebar from '../components/Sidebar.jsx';
155
+
156
+ const StatCard = ({ title, value, change }) => (
157
+ <div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
158
+ <p className="text-gray-400 text-sm">{title}</p>
159
+ <p className="text-3xl font-bold text-white mt-1">{value}</p>
160
+ {change && <p className="text-green-400 text-sm mt-1">{change}</p>}
161
+ </div>
162
+ );
163
+
164
+ export default function Dashboard() {
165
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
166
+ return (
167
+ <div className="min-h-screen bg-gray-950 text-white">
168
+ <Sidebar />
169
+ <main className="ml-64 p-8">
170
+ <div className="mb-8">
171
+ <h1 className="text-2xl font-bold">Bienvenido, {user.name || 'Admin'}</h1>
172
+ <p className="text-gray-400 text-sm mt-1">Resumen del sistema</p>
173
+ </div>
174
+ <div className="grid grid-cols-4 gap-4 mb-8">
175
+ <StatCard title="Usuarios" value="128" change="+8 este mes" />
176
+ <StatCard title="Ingresos" value="$12,400" change="+12% vs anterior" />
177
+ <StatCard title="Pedidos" value="54" change="+5 hoy" />
178
+ <StatCard title="Crecimiento" value="+12%" />
179
+ </div>
180
+ <div className="bg-gray-900 border border-gray-800 rounded-xl p-6">
181
+ <h3 className="text-white font-semibold mb-4">Actividad reciente</h3>
182
+ <p className="text-gray-500 text-sm">No hay actividad reciente.</p>
183
+ </div>
184
+ </main>
185
+ </div>
186
+ );
187
+ }
188
+ `);
189
+ }
190
+
191
+ async function generateAuthPages(fe) {
192
+ await fse.ensureDir(path.join(fe, 'pages', 'auth'));
193
+ }
194
+
195
+ async function generateRoles(be) {
196
+ await fse.ensureDir(path.join(be, 'middlewares'));
197
+ await fse.writeFile(path.join(be, 'middlewares', 'roles.middleware.js'),
198
+ `export const requireRole = (...roles) => (req, res, next) => {
199
+ if (!req.user) return res.status(401).json({ error: 'No autenticado' });
200
+ if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acceso denegado. Requiere: ' + roles.join(' o ') });
201
+ next();
202
+ };
203
+ // Roles: 'admin' | 'editor' | 'user'
204
+ // Uso: router.get('/admin', authMiddleware, requireRole('admin'), handler)
205
+ `);
206
+ }
207
+
208
+ async function generateStripe(be) {
209
+ await fse.ensureDir(path.join(be, 'payments'));
210
+ await fse.writeFile(path.join(be, 'payments', 'stripe.routes.js'),
211
+ `import { Router } from 'express';
212
+ import Stripe from 'stripe';
213
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
214
+ const router = Router();
215
+
216
+ router.post('/create-checkout', async (req, res) => {
217
+ const { priceId } = req.body;
218
+ const session = await stripe.checkout.sessions.create({
219
+ mode: 'payment',
220
+ line_items: [{ price: priceId, quantity: 1 }],
221
+ success_url: process.env.FRONTEND_URL + '/success',
222
+ cancel_url: process.env.FRONTEND_URL + '/cancel',
223
+ });
224
+ res.json({ url: session.url });
225
+ });
226
+
227
+ export default router;
228
+ `);
229
+ }
230
+
231
+ async function generateWhatsApp(fe) {
232
+ await fse.ensureDir(path.join(fe, 'components'));
233
+ await fse.writeFile(path.join(fe, 'components', 'WhatsAppButton.jsx'),
234
+ `export default function WhatsAppButton({ phone = '1234567890', message = 'Hola, me interesa más información' }) {
235
+ return (
236
+ <a href={\`https://wa.me/\${phone}?text=\${encodeURIComponent(message)}\`} target="_blank" rel="noopener noreferrer"
237
+ className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-green-500 hover:bg-green-400 rounded-full flex items-center justify-center shadow-lg transition" title="WhatsApp">
238
+ <svg viewBox="0 0 24 24" fill="white" className="w-7 h-7"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>
239
+ </a>
240
+ );
241
+ }
242
+ `);
243
+ }
244
+
245
+ async function generateDarkMode(fe) {
246
+ await fse.ensureDir(path.join(fe, 'components'));
247
+ await fse.writeFile(path.join(fe, 'components', 'DarkModeToggle.jsx'),
248
+ `import { useState, useEffect } from 'react';
249
+ export default function DarkModeToggle() {
250
+ const [dark, setDark] = useState(() => localStorage.getItem('theme') !== 'light');
251
+ useEffect(() => {
252
+ document.documentElement.classList.toggle('dark', dark);
253
+ localStorage.setItem('theme', dark ? 'dark' : 'light');
254
+ }, [dark]);
255
+ return (
256
+ <button onClick={() => setDark(d => !d)} className="p-2 rounded-lg border border-gray-700 text-gray-400 hover:text-white transition text-sm" title="Cambiar tema">
257
+ {dark ? '☀️' : '🌙'}
258
+ </button>
259
+ );
260
+ }
261
+ `);
262
+ }
@@ -0,0 +1,166 @@
1
+ import path from 'path';
2
+ import fse from 'fs-extra';
3
+ import chalk from 'chalk';
4
+ import boxen from 'boxen';
5
+ import { spawnSync } from 'child_process';
6
+ import { isInstalled } from '../utils.js';
7
+
8
+ // ── GitHub Actions CI ─────────────────────────────────────────────────────────
9
+ export async function generateDeploy(config, root) {
10
+ const ciDir = path.join(root, '.github', 'workflows');
11
+ await fse.ensureDir(ciDir);
12
+ const isPython = ['fastapi','django','flask'].includes(config.backend);
13
+
14
+ await fse.writeFile(path.join(ciDir, 'ci.yml'),
15
+ `name: CI/CD Pipeline
16
+
17
+ on:
18
+ push:
19
+ branches: [main, develop]
20
+ pull_request:
21
+ branches: [main]
22
+
23
+ jobs:
24
+ frontend:
25
+ name: Frontend — Build & Test
26
+ runs-on: ubuntu-latest
27
+ defaults:
28
+ run:
29
+ working-directory: frontend
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 20
35
+ cache: npm
36
+ cache-dependency-path: frontend/package-lock.json
37
+ - run: npm ci
38
+ - run: npm run lint --if-present
39
+ - run: npm test --if-present
40
+ - run: npm run build
41
+
42
+ backend:
43
+ name: Backend — Lint & Test
44
+ runs-on: ubuntu-latest
45
+ defaults:
46
+ run:
47
+ working-directory: backend
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ ${isPython
51
+ ? ` - uses: actions/setup-python@v5
52
+ with:
53
+ python-version: '3.11'
54
+ - run: pip install -r requirements.txt
55
+ - run: python -m pytest --if-present`
56
+ : ` - uses: actions/setup-node@v4
57
+ with:
58
+ node-version: 20
59
+ - run: npm ci
60
+ - run: npm run lint --if-present
61
+ - run: npm test --if-present`}
62
+ `);
63
+
64
+ await fse.writeFile(path.join(ciDir, 'deploy.yml'),
65
+ `name: Deploy
66
+
67
+ on:
68
+ push:
69
+ branches: [main]
70
+
71
+ jobs:
72
+ deploy-frontend:
73
+ name: Deploy Frontend → Vercel
74
+ runs-on: ubuntu-latest
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+ - uses: actions/setup-node@v4
78
+ with:
79
+ node-version: 20
80
+ - run: npm install -g vercel
81
+ - run: cd frontend && npm ci && npm run build
82
+ - run: vercel --prod --token \${{ secrets.VERCEL_TOKEN }} --yes
83
+ env:
84
+ VERCEL_ORG_ID: \${{ secrets.VERCEL_ORG_ID }}
85
+ VERCEL_PROJECT_ID: \${{ secrets.VERCEL_PROJECT_ID }}
86
+
87
+ deploy-backend:
88
+ name: Deploy Backend → Railway
89
+ runs-on: ubuntu-latest
90
+ needs: deploy-frontend
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ - uses: railwayapp/railway-github-action@v1
94
+ with:
95
+ railway-token: \${{ secrets.RAILWAY_TOKEN }}
96
+ service: backend
97
+ `);
98
+ }
99
+
100
+ // ── novatec build ─────────────────────────────────────────────────────────────
101
+ export async function runBuild(options) {
102
+ const root = process.cwd();
103
+ const hasFe = await fse.pathExists(path.join(root, 'frontend'));
104
+ const hasBe = await fse.pathExists(path.join(root, 'backend'));
105
+
106
+ console.log();
107
+ console.log(chalk.white(' Building proyecto...\n'));
108
+
109
+ if ((options.frontend || !options.backend) && hasFe) {
110
+ const spinner = (await import('ora')).default({ text: chalk.white('Build frontend...'), spinner: 'dots', color: 'white' }).start();
111
+ const r = spawnSync('npm run build', { cwd: path.join(root, 'frontend'), shell: true, stdio: 'pipe', encoding: 'utf8' });
112
+ r.status === 0 ? spinner.succeed(chalk.white('Frontend build OK')) : spinner.fail(chalk.red('Frontend build falló\n' + r.stderr));
113
+ }
114
+
115
+ if ((options.backend || !options.frontend) && hasBe) {
116
+ const pkgPath = path.join(root, 'backend', 'package.json');
117
+ if (await fse.pathExists(pkgPath)) {
118
+ const pkg = await fse.readJSON(pkgPath);
119
+ if (pkg.scripts?.build) {
120
+ const spinner = (await import('ora')).default({ text: chalk.white('Build backend...'), spinner: 'dots', color: 'white' }).start();
121
+ const r = spawnSync('npm run build', { cwd: path.join(root, 'backend'), shell: true, stdio: 'pipe', encoding: 'utf8' });
122
+ r.status === 0 ? spinner.succeed(chalk.white('Backend build OK')) : spinner.fail(chalk.red('Backend build falló'));
123
+ }
124
+ }
125
+ }
126
+
127
+ console.log();
128
+ console.log(' ' + chalk.white('✔ Build completado'));
129
+ console.log();
130
+ }
131
+
132
+ // ── novatec deploy ────────────────────────────────────────────────────────────
133
+ export async function runDeploy(options) {
134
+ console.log();
135
+ console.log(boxen(
136
+ chalk.bold.white(' DEPLOY — NovaTec CLI\n\n') +
137
+ chalk.white(' Frontend → Vercel\n') +
138
+ chalk.white(' Backend → Railway\n\n') +
139
+ chalk.gray(' Asegúrate de tener instalados:\n') +
140
+ chalk.gray(' npm install -g vercel\n') +
141
+ chalk.gray(' npm install -g @railway/cli'),
142
+ { padding: 1, margin: { left: 2 }, borderStyle: 'round', borderColor: 'white', dimBorder: true }
143
+ ));
144
+
145
+ const root = process.cwd();
146
+
147
+ if ((options.frontend || !options.backend)) {
148
+ if (!isInstalled('vercel')) {
149
+ console.log(chalk.yellow('\n Instalando Vercel CLI...'));
150
+ spawnSync('npm install -g vercel', { shell: true, stdio: 'inherit' });
151
+ }
152
+ console.log(chalk.white('\n Desplegando frontend en Vercel...'));
153
+ spawnSync('vercel --prod', { cwd: path.join(root, 'frontend'), shell: true, stdio: 'inherit' });
154
+ }
155
+
156
+ if ((options.backend || !options.frontend)) {
157
+ if (!isInstalled('railway')) {
158
+ console.log(chalk.yellow('\n Instalando Railway CLI...'));
159
+ spawnSync('npm install -g @railway/cli', { shell: true, stdio: 'inherit' });
160
+ }
161
+ console.log(chalk.white('\n Desplegando backend en Railway...'));
162
+ spawnSync('railway up', { cwd: path.join(root, 'backend'), shell: true, stdio: 'inherit' });
163
+ }
164
+
165
+ console.log();
166
+ }