@openfactu/cli 0.0.7 → 0.0.8

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.
@@ -13,6 +13,9 @@ const os_1 = __importDefault(require("os"));
13
13
  const fs_1 = __importDefault(require("fs"));
14
14
  const path_1 = __importDefault(require("path"));
15
15
  const logger_1 = require("../utils/logger");
16
+ const env_1 = require("../utils/env");
17
+ const monitoring_1 = require("../utils/monitoring");
18
+ const helpers_1 = require("../utils/helpers");
16
19
  const REPO_URL = 'https://github.com/OpenFactu/platform.git';
17
20
  const GITHUB_OWNER = 'OpenFactu';
18
21
  const GITHUB_REPO = 'platform';
@@ -66,28 +69,206 @@ function getAvailableBranches() {
66
69
  function checkDocker() {
67
70
  try {
68
71
  (0, child_process_1.execSync)('docker --version', { stdio: 'pipe' });
69
- (0, child_process_1.execSync)('docker compose version', { stdio: 'pipe' });
72
+ try {
73
+ (0, child_process_1.execSync)('docker compose version', { stdio: 'pipe' });
74
+ }
75
+ catch {
76
+ (0, child_process_1.execSync)('docker-compose --version', { stdio: 'pipe' });
77
+ }
70
78
  return true;
71
79
  }
72
80
  catch {
73
81
  return false;
74
82
  }
75
83
  }
84
+ function validateRepoStructure(targetDir) {
85
+ const required = [
86
+ 'package.json',
87
+ 'docker-compose.yml',
88
+ 'apps/web',
89
+ 'apps/server',
90
+ ];
91
+ const missing = required.filter(f => !fs_1.default.existsSync(path_1.default.join(targetDir, f)));
92
+ return { valid: missing.length === 0, missing };
93
+ }
94
+ const installLog = [];
95
+ function logStep(message, status = 'info') {
96
+ const timestamp = new Date().toISOString();
97
+ const prefix = status === 'success' ? '✓' : status === 'error' ? '✗' : status === 'warn' ? '⚠' : '•';
98
+ const entry = `[${timestamp}] ${prefix} ${message}`;
99
+ installLog.push(entry);
100
+ }
101
+ function writeInstallLog(targetDir) {
102
+ try {
103
+ const logDir = path_1.default.join(targetDir, '.openfactu');
104
+ if (!fs_1.default.existsSync(logDir)) {
105
+ fs_1.default.mkdirSync(logDir, { recursive: true });
106
+ }
107
+ const logFile = path_1.default.join(logDir, 'install.log');
108
+ fs_1.default.writeFileSync(logFile, installLog.join('\n') + '\n');
109
+ }
110
+ catch { }
111
+ }
112
+ function generateEnvConfig(dbPassword) {
113
+ const postgresUser = 'openfactu';
114
+ const postgresDb = 'openfactudb';
115
+ const jwtSecret = (0, helpers_1.generatePassword)(48);
116
+ const sessionSecret = (0, helpers_1.generatePassword)(32);
117
+ const adminPassword = (0, helpers_1.generatePassword)(16);
118
+ return {
119
+ POSTGRES_USER: postgresUser,
120
+ POSTGRES_PASSWORD: dbPassword,
121
+ POSTGRES_DB: postgresDb,
122
+ DATABASE_URL: `postgresql://${postgresUser}:${encodeURIComponent(dbPassword)}@db:5432/${postgresDb}`,
123
+ SERVER_PORT: '3000',
124
+ WEB_PORT: '8080',
125
+ DB_PORT: '5432',
126
+ JWT_SECRET: jwtSecret,
127
+ SESSION_SECRET: sessionSecret,
128
+ NODE_ENV: 'production',
129
+ HOST: 'localhost',
130
+ CORS_ORIGIN: 'http://localhost:8080',
131
+ VITE_API_URL: 'http://localhost:3000',
132
+ ADMIN_EMAIL: 'admin@openfactu.local',
133
+ ADMIN_PASSWORD: adminPassword,
134
+ };
135
+ }
136
+ function generateEnvFileContent(envConfig) {
137
+ return `# ============================================================
138
+ # OpenFactu - Configuración
139
+ # Generado automáticamente por @openfactu/cli
140
+ # ============================================================
141
+ # IMPORTANTE: Revisa y ajusta los valores marcados con [EDITAR]
142
+ # ============================================================
143
+
144
+ # ── Base de datos ──────────────────────────────────────────
145
+ # [EDITAR] Si cambias el puerto de PostgreSQL, actualiza DATABASE_URL
146
+ POSTGRES_USER=${envConfig.POSTGRES_USER}
147
+ POSTGRES_PASSWORD=${envConfig.POSTGRES_PASSWORD}
148
+ POSTGRES_DB=${envConfig.POSTGRES_DB}
149
+ DB_PORT=${envConfig.DB_PORT}
150
+
151
+ # [EDITAR] La URL interna del contenedor (no cambiar a menos que uses otro host de BD)
152
+ DATABASE_URL=${envConfig.DATABASE_URL}
153
+
154
+ # ── Puertos de la aplicación ───────────────────────────────
155
+ # [EDITAR] Cambia estos puertos si hay conflictos en tu servidor
156
+ SERVER_PORT=${envConfig.SERVER_PORT}
157
+ WEB_PORT=${envConfig.WEB_PORT}
158
+
159
+ # ── Seguridad ─────────────────────────────────────────────
160
+ # [NO EDITAR] Secrets generados automáticamente
161
+ JWT_SECRET=${envConfig.JWT_SECRET}
162
+ SESSION_SECRET=${envConfig.SESSION_SECRET}
163
+
164
+ # ── Entorno ────────────────────────────────────────────────
165
+ NODE_ENV=${envConfig.NODE_ENV}
166
+
167
+ # ── URLs y acceso externo ──────────────────────────────────
168
+ # [EDITAR] Cambia HOST por tu dominio o IP pública
169
+ HOST=${envConfig.HOST}
170
+
171
+ # [EDITAR] Cambia estas URLs según tu dominio/IP y puertos
172
+ # Ejemplo con dominio: https://erp.miempresa.com
173
+ # Ejemplo con IP: http://192.168.1.100:8080
174
+ CORS_ORIGIN=${envConfig.CORS_ORIGIN}
175
+ VITE_API_URL=${envConfig.VITE_API_URL}
176
+
177
+ # ── Credenciales de administrador ──────────────────────────
178
+ # [EDITAR] Cambia el email y password del admin inicial
179
+ ADMIN_EMAIL=${envConfig.ADMIN_EMAIL}
180
+ ADMIN_PASSWORD=${envConfig.ADMIN_PASSWORD}
181
+ `;
182
+ }
183
+ function installService(targetDir, dockerCmd, serviceName, unitPath) {
184
+ const unitContent = `[Unit]
185
+ Description=OpenFactu ERP Platform
186
+ After=docker.service network-online.target
187
+ Requires=docker.service
188
+ Wants=network-online.target
189
+
190
+ [Service]
191
+ Type=oneshot
192
+ RemainAfterExit=yes
193
+ WorkingDirectory=${targetDir}
194
+ ExecStart=${dockerCmd} -f docker-compose.yml up -d
195
+ ExecStop=${dockerCmd} -f docker-compose.yml down
196
+ Restart=on-failure
197
+ RestartSec=10
198
+
199
+ [Install]
200
+ WantedBy=multi-user.target
201
+ `;
202
+ fs_1.default.writeFileSync(`/tmp/${serviceName}.service`, unitContent);
203
+ (0, child_process_1.execSync)(`sudo mv /tmp/${serviceName}.service ${unitPath}`, { stdio: 'pipe' });
204
+ (0, child_process_1.execSync)('sudo systemctl daemon-reload', { stdio: 'pipe' });
205
+ (0, child_process_1.execSync)(`sudo systemctl enable ${serviceName}`, { stdio: 'pipe' });
206
+ logger_1.log.success(`Servicio ${serviceName} instalado y habilitado`);
207
+ logger_1.log.dim(` sudo systemctl start ${serviceName}`);
208
+ logger_1.log.dim(` sudo systemctl status ${serviceName}`);
209
+ }
76
210
  function registerInstallCommand(program) {
77
211
  program
78
212
  .command('install [directory]')
79
213
  .description('Descarga e instala OpenFactu en un directorio')
80
214
  .option('-t, --tag <tag>', 'Versión/tag específico (ej: v1.0.0)')
81
- .option('-b, --branch <branch>', 'Branch específica (default: main)')
215
+ .option('-b, --branch <branch>', 'Branch específico (default: main)')
82
216
  .option('--repo <url>', 'URL del repositorio', REPO_URL)
83
217
  .option('--skip-deps', 'No instalar dependencias (npm install)')
218
+ .option('--mode <mode>', 'Modo: full, docker, minimal, download (default: interactive)')
219
+ .option('--no-preflight', 'Saltar chequeos previos')
220
+ .option('--no-healthcheck', 'Saltar health checks post-instalación')
221
+ .option('--generate-env', 'Generar .env con credenciales seguras aleatorias')
222
+ .option('--service', 'Instalar como servicio systemd después de instalar')
223
+ .option('--monitoring', 'Incluir stack de monitoreo (Grafana, Prometheus, etc.)')
224
+ .option('--with-analytics', 'Incluir stack completo de analítica (Loki, cAdvisor, Node Exporter)')
225
+ .option('-y, --yes', 'Aceptar defaults sin preguntar (non-interactive)')
84
226
  .action(async (directory, opts) => {
85
227
  console.log();
86
228
  console.log(chalk_1.default.bold.white(' OpenFactu — Instalación'));
87
229
  console.log(chalk_1.default.dim(' ────────────────────────────────────'));
88
230
  console.log();
231
+ let targetDir = directory;
89
232
  try {
90
233
  const repoUrl = opts.repo;
234
+ const nonInteractive = opts.yes || false;
235
+ logStep('Inicio de instalación', 'info');
236
+ // 0. Preflight checks
237
+ if (opts.preflight !== false) {
238
+ const checkSpinner = (0, ora_1.default)('Ejecutando chequeos del sistema...').start();
239
+ const checks = (0, helpers_1.runPreflightChecks)(directory || os_1.default.homedir());
240
+ const fails = checks.filter(c => c.status === 'fail');
241
+ const warns = checks.filter(c => c.status === 'warn');
242
+ if (fails.length > 0) {
243
+ checkSpinner.fail('Chequeos fallidos');
244
+ logStep(`Preflight fallido: ${fails.map(f => f.name).join(', ')}`, 'error');
245
+ logger_1.log.blank();
246
+ logger_1.log.error('Requisitos no cumplidos:');
247
+ for (const f of fails) {
248
+ logger_1.log.error(` ✗ ${f.name}: ${f.message}`);
249
+ }
250
+ logger_1.log.blank();
251
+ if (!nonInteractive) {
252
+ const { continueAnyway } = await inquirer_1.default.prompt([
253
+ { type: 'confirm', name: 'continueAnyway', message: 'Continuar de todos modos?', default: false },
254
+ ]);
255
+ if (!continueAnyway)
256
+ return;
257
+ }
258
+ }
259
+ else if (warns.length > 0) {
260
+ checkSpinner.warn('Chequeos con advertencias');
261
+ logStep(`Preflight con advertencias: ${warns.map(w => w.name).join(', ')}`, 'warn');
262
+ for (const w of warns) {
263
+ logger_1.log.warn(` ⚠ ${w.name}: ${w.message}`);
264
+ }
265
+ }
266
+ else {
267
+ checkSpinner.succeed(`${checks.length} chequeos pasados`);
268
+ logStep('Preflight OK', 'success');
269
+ }
270
+ logger_1.log.blank();
271
+ }
91
272
  // 1. Obtener releases de GitHub
92
273
  const fetchSpinner = (0, ora_1.default)('Consultando releases en GitHub...').start();
93
274
  const releases = await getGithubReleases();
@@ -101,10 +282,12 @@ function registerInstallCommand(program) {
101
282
  else if (opts.branch) {
102
283
  ref = opts.branch;
103
284
  }
285
+ else if (nonInteractive) {
286
+ const stable = releases.filter((r) => !r.prerelease);
287
+ ref = stable.length > 0 ? stable[0].tag_name : 'main';
288
+ }
104
289
  else {
105
- // Menú interactivo
106
290
  const choices = [];
107
- // Releases estables primero
108
291
  const stable = releases.filter((r) => !r.prerelease);
109
292
  const prerelease = releases.filter((r) => r.prerelease);
110
293
  for (const rel of stable.slice(0, 10)) {
@@ -150,43 +333,53 @@ function registerInstallCommand(program) {
150
333
  }
151
334
  logger_1.log.info(`Versión seleccionada: ${chalk_1.default.cyan(ref)}`);
152
335
  // 3. Directorio destino
153
- let targetDir = directory;
154
336
  if (!targetDir) {
155
- const { dir } = await inquirer_1.default.prompt([
156
- {
157
- type: 'input',
158
- name: 'dir',
159
- message: 'Directorio de instalación:',
160
- default: path_1.default.join(os_1.default.homedir(), 'openfactu'),
161
- },
162
- ]);
163
- targetDir = dir;
337
+ if (nonInteractive) {
338
+ targetDir = path_1.default.join(os_1.default.homedir(), 'openfactu');
339
+ }
340
+ else {
341
+ const { dir } = await inquirer_1.default.prompt([
342
+ {
343
+ type: 'input',
344
+ name: 'dir',
345
+ message: 'Directorio de instalación:',
346
+ default: path_1.default.join(os_1.default.homedir(), 'openfactu'),
347
+ },
348
+ ]);
349
+ targetDir = dir;
350
+ }
164
351
  }
165
352
  targetDir = path_1.default.resolve(targetDir);
166
- // Verificar si el directorio ya existe
167
353
  if (fs_1.default.existsSync(targetDir)) {
168
354
  const contents = fs_1.default.readdirSync(targetDir);
169
355
  if (contents.length > 0) {
170
- const { overwrite } = await inquirer_1.default.prompt([
171
- {
172
- type: 'confirm',
173
- name: 'overwrite',
174
- message: `El directorio ${targetDir} no está vacío. ¿Continuar?`,
175
- default: false,
176
- },
177
- ]);
178
- if (!overwrite) {
179
- logger_1.log.info('Instalación cancelada');
180
- return;
356
+ if (nonInteractive) {
357
+ logger_1.log.warn(`Directorio ${targetDir} no está vacío, se usará igual`);
358
+ }
359
+ else {
360
+ const { overwrite } = await inquirer_1.default.prompt([
361
+ {
362
+ type: 'confirm',
363
+ name: 'overwrite',
364
+ message: `El directorio ${targetDir} no está vacío. ¿Continuar?`,
365
+ default: false,
366
+ },
367
+ ]);
368
+ if (!overwrite) {
369
+ logger_1.log.info('Instalación cancelada');
370
+ return;
371
+ }
181
372
  }
182
373
  }
183
374
  }
184
375
  logger_1.log.info(`Directorio: ${chalk_1.default.dim(targetDir)}`);
376
+ logStep(`Directorio: ${targetDir}`, 'info');
185
377
  logger_1.log.blank();
186
- // 4. Crear directorio si no existe (con sudo si hace falta)
378
+ // 4. Crear directorio
187
379
  if (!fs_1.default.existsSync(targetDir)) {
188
380
  try {
189
381
  fs_1.default.mkdirSync(targetDir, { recursive: true });
382
+ logStep('Directorio creado', 'success');
190
383
  }
191
384
  catch (mkdirErr) {
192
385
  if (mkdirErr.code === 'EACCES') {
@@ -194,11 +387,13 @@ function registerInstallCommand(program) {
194
387
  try {
195
388
  const user = process.env.USER || process.env.USERNAME || 'root';
196
389
  (0, child_process_1.execSync)(`sudo mkdir -p "${targetDir}" && sudo chown -R ${user}:${user} "${targetDir}"`, {
197
- stdio: 'inherit',
390
+ stdio: 'pipe',
198
391
  });
392
+ logStep('Directorio creado con sudo', 'success');
199
393
  }
200
394
  catch {
201
395
  logger_1.log.error(`No se pudo crear ${targetDir}. Ejecuta con sudo o elige otro directorio.`);
396
+ logStep(`Error creando directorio: ${targetDir}`, 'error');
202
397
  return;
203
398
  }
204
399
  }
@@ -209,6 +404,7 @@ function registerInstallCommand(program) {
209
404
  }
210
405
  // 5. Clonar repositorio
211
406
  const cloneSpinner = (0, ora_1.default)('Descargando OpenFactu...').start();
407
+ logStep('Iniciando clonación del repositorio', 'info');
212
408
  const isTag = releases.some((r) => r.tag_name === ref);
213
409
  const cloneCmd = isTag
214
410
  ? `git clone --depth 1 --branch ${ref} ${repoUrl} "${targetDir}"`
@@ -216,72 +412,755 @@ function registerInstallCommand(program) {
216
412
  try {
217
413
  (0, child_process_1.execSync)(cloneCmd, { stdio: 'pipe', timeout: 120000 });
218
414
  cloneSpinner.succeed('Código descargado');
415
+ logStep(`Repositorio clonado: ${ref}`, 'success');
219
416
  }
220
417
  catch (err) {
221
- // Fallback: clonar todo y checkout
222
418
  try {
223
419
  cloneSpinner.text = 'Descargando (método alternativo)...';
224
420
  (0, child_process_1.execSync)(`git clone ${repoUrl} "${targetDir}"`, { stdio: 'pipe', timeout: 180000 });
225
421
  (0, child_process_1.execSync)(`git checkout ${ref}`, { cwd: targetDir, stdio: 'pipe' });
226
422
  cloneSpinner.succeed('Código descargado');
423
+ logStep(`Repositorio clonado (alternativo): ${ref}`, 'success');
227
424
  }
228
425
  catch (err2) {
229
426
  cloneSpinner.fail('Error al descargar: ' + err2.message);
427
+ logStep(`Error clonando repositorio: ${err2.message}`, 'error');
230
428
  return;
231
429
  }
232
430
  }
233
- // 6. Copiar .env.example a .env
234
- const envExample = path_1.default.join(targetDir, '.env.example');
235
- const envFile = path_1.default.join(targetDir, '.env');
236
- if (fs_1.default.existsSync(envExample) && !fs_1.default.existsSync(envFile)) {
237
- fs_1.default.copyFileSync(envExample, envFile);
238
- logger_1.log.success('Archivo .env creado desde .env.example');
431
+ // 6. Validar estructura del repo
432
+ const validation = validateRepoStructure(targetDir);
433
+ if (!validation.valid) {
434
+ logger_1.log.warn(`Estructura incompleta: ${validation.missing.join(', ')}`);
435
+ logStep(`Estructura incompleta: ${validation.missing.join(', ')}`, 'warn');
436
+ logger_1.log.dim(' Algunos comandos pueden no funcionar correctamente');
437
+ }
438
+ else {
439
+ logStep('Estructura del repositorio válida', 'success');
239
440
  }
240
- // 7. Preguntar modo de instalación
241
- const hasDocker = checkDocker();
242
- if (!hasDocker) {
243
- logger_1.log.warn('Docker no detectado. OpenFactu requiere Docker para funcionar.');
244
- logger_1.log.dim(' Instala Docker Desktop: https://docs.docker.com/get-docker/');
441
+ // 7. Configurar .env — las credenciales (incluida la contraseña de BD) se
442
+ // fijan AQUI, antes del primer arranque, para que el volumen de Postgres
443
+ // se inicialice con ellas y no acabe usando la contraseña por defecto.
444
+ {
445
+ const envFile = path_1.default.join(targetDir, '.env');
446
+ const envExample = path_1.default.join(targetDir, '.env.example');
447
+ // Decidir la contraseña de PostgreSQL.
448
+ let dbPassword;
449
+ if (opts.generateEnv || nonInteractive) {
450
+ dbPassword = (0, helpers_1.generatePassword)(24);
451
+ }
452
+ else {
453
+ const suggested = (0, helpers_1.generatePassword)(24);
454
+ const { pw } = await inquirer_1.default.prompt([
455
+ {
456
+ type: 'input',
457
+ name: 'pw',
458
+ message: 'Contraseña de PostgreSQL (Enter = generar una segura):',
459
+ default: suggested,
460
+ },
461
+ ]);
462
+ dbPassword = typeof pw === 'string' && pw.trim() ? pw.trim() : suggested;
463
+ }
464
+ const envSpinner = (0, ora_1.default)('Configurando .env con credenciales seguras...').start();
465
+ const envConfig = generateEnvConfig(dbPassword);
466
+ // Plantilla base: .env.example si existe (preserva comentarios y claves
467
+ // propias de la plataforma); si no, un .env generado desde cero.
468
+ const baseContent = fs_1.default.existsSync(envExample)
469
+ ? fs_1.default.readFileSync(envExample, 'utf-8')
470
+ : generateEnvFileContent(envConfig);
471
+ fs_1.default.writeFileSync(envFile, (0, env_1.applyEnvOverrides)(baseContent, envConfig));
472
+ envSpinner.succeed('.env configurado con credenciales seguras');
473
+ logStep('.env configurado con credenciales seguras', 'success');
474
+ logger_1.log.blank();
475
+ logger_1.log.info(`${chalk_1.default.dim('Admin:')} ${envConfig.ADMIN_EMAIL} / ${chalk_1.default.yellow(envConfig.ADMIN_PASSWORD)}`);
476
+ logger_1.log.info(`${chalk_1.default.dim('DB Password:')} ${chalk_1.default.yellow(envConfig.POSTGRES_PASSWORD)}`);
477
+ logger_1.log.dim(' Guarda estas credenciales en un lugar seguro');
245
478
  logger_1.log.blank();
246
479
  }
247
- const { installMode } = await inquirer_1.default.prompt([
248
- {
249
- type: 'list',
250
- name: 'installMode',
251
- message: 'Modo de instalación:',
252
- choices: [
253
- ...(hasDocker ? [{
254
- name: `${chalk_1.default.green('Docker')} ${chalk_1.default.dim('— recomendado, funciona en Windows/Mac/Linux')}`,
255
- value: 'docker',
256
- }] : []),
480
+ // 8. Determinar modo de instalación
481
+ let installMode = opts.mode;
482
+ if (!installMode) {
483
+ const hasDocker = checkDocker();
484
+ if (!hasDocker) {
485
+ logger_1.log.warn('Docker no detectado. OpenFactu requiere Docker para funcionar.');
486
+ logger_1.log.dim(' Instala Docker: https://docs.docker.com/get-docker/');
487
+ logger_1.log.blank();
488
+ }
489
+ if (nonInteractive) {
490
+ installMode = hasDocker ? 'docker' : 'download';
491
+ }
492
+ else {
493
+ const disk = (0, helpers_1.checkDiskSpace)(targetDir);
494
+ const { selectedMode } = await inquirer_1.default.prompt([
257
495
  {
258
- name: `${chalk_1.default.dim('Solo descargar')} ${chalk_1.default.dim('— instalar dependencias manualmente después')}`,
259
- value: 'none',
496
+ type: 'list',
497
+ name: 'selectedMode',
498
+ message: 'Modo de instalación:',
499
+ choices: [
500
+ ...(hasDocker ? [
501
+ {
502
+ name: `${chalk_1.default.green('Completa (Docker)')} ${chalk_1.default.dim('— build + up + setup completo')}`,
503
+ value: 'full',
504
+ },
505
+ {
506
+ name: `${chalk_1.default.cyan('Docker')} ${chalk_1.default.dim('— build + up, sin setup DB')}`,
507
+ value: 'docker',
508
+ },
509
+ {
510
+ name: `${chalk_1.default.yellow('Mínima')} ${chalk_1.default.dim('— solo compose up, sin build')}`,
511
+ value: 'minimal',
512
+ },
513
+ ] : []),
514
+ {
515
+ name: `${chalk_1.default.dim('Solo descarga')} ${chalk_1.default.dim('— sin Docker')}`,
516
+ value: 'download',
517
+ },
518
+ ],
519
+ },
520
+ ]);
521
+ installMode = selectedMode;
522
+ }
523
+ }
524
+ // Verificar conflictos de puertos ANTES de hacer más preguntas
525
+ if (installMode === 'full' || installMode === 'docker' || installMode === 'minimal') {
526
+ const commonPorts = [
527
+ { port: 5432, name: 'PostgreSQL', container: 'db' },
528
+ { port: 8080, name: 'Web', container: 'web' },
529
+ { port: 3000, name: 'API Server', container: 'server' },
530
+ ];
531
+ const conflicts = commonPorts.filter(p => {
532
+ try {
533
+ // Excluir docker-pr (proxies de Docker) que pueden quedar colgados
534
+ const output = (0, child_process_1.execSync)(`lsof -i :${p.port} -sTCP:LISTEN 2>/dev/null | grep -v 'docker-pr' | grep -v 'COMMAND' || true`, { stdio: 'pipe' }).toString().trim();
535
+ return output.length > 0;
536
+ }
537
+ catch {
538
+ return false;
539
+ }
540
+ });
541
+ if (conflicts.length > 0 && !nonInteractive) {
542
+ logger_1.log.blank();
543
+ logger_1.log.warn('Puertos en conflicto detectados:');
544
+ for (const c of conflicts) {
545
+ let processInfo = '';
546
+ try {
547
+ const pid = (0, child_process_1.execSync)(`lsof -i :${c.port} -sTCP:LISTEN -t 2>/dev/null | grep -v 'docker-pr' | head -1`, { stdio: 'pipe' }).toString().trim();
548
+ if (pid) {
549
+ const procName = (0, child_process_1.execSync)(`ps -p ${pid} -o comm= 2>/dev/null || echo 'desconocido'`, { stdio: 'pipe' }).toString().trim();
550
+ processInfo = ` (${procName})`;
551
+ }
552
+ }
553
+ catch { }
554
+ logger_1.log.warn(` Puerto ${c.port} (${c.name}): ocupado${processInfo}`);
555
+ }
556
+ logger_1.log.blank();
557
+ const { portAction } = await inquirer_1.default.prompt([
558
+ {
559
+ type: 'list',
560
+ name: 'portAction',
561
+ message: '¿Cómo resolver el conflicto?',
562
+ choices: [
563
+ { name: 'Detener el proceso que ocupa el puerto', value: 'stop' },
564
+ { name: 'Usar puertos alternativos (5433, 8081, 3001)', value: 'alternate' },
565
+ { name: 'Continuar igual (puede fallar)', value: 'continue' },
566
+ { name: 'Cancelar instalación', value: 'cancel' },
567
+ ],
260
568
  },
261
- ],
262
- },
263
- ]);
264
- if (installMode === 'docker') {
265
- // Docker: build + up
266
- const dockerSpinner = (0, ora_1.default)('Construyendo contenedores Docker...').start();
267
- try {
268
- (0, child_process_1.execSync)('docker compose build', { cwd: targetDir, stdio: 'pipe', timeout: 300000 });
269
- dockerSpinner.succeed('Contenedores construidos');
270
- const { startNow } = await inquirer_1.default.prompt([
271
- { type: 'confirm', name: 'startNow', message: '¿Arrancar los servicios?', default: true },
272
569
  ]);
273
- if (startNow) {
274
- const upSpinner = (0, ora_1.default)('Levantando servicios...').start();
275
- (0, child_process_1.execSync)('docker compose up -d', { cwd: targetDir, stdio: 'pipe', timeout: 120000 });
276
- upSpinner.succeed('Servicios levantados');
570
+ if (portAction === 'cancel') {
571
+ logger_1.log.info('Instalación cancelada');
572
+ writeInstallLog(targetDir);
573
+ return;
574
+ }
575
+ if (portAction === 'stop') {
576
+ for (const c of conflicts) {
577
+ const stopSpinner = (0, ora_1.default)(`Deteniendo proceso en puerto ${c.port}...`).start();
578
+ try {
579
+ const pid = (0, child_process_1.execSync)(`lsof -i :${c.port} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe' }).toString().trim();
580
+ if (pid) {
581
+ (0, child_process_1.execSync)(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'pipe' });
582
+ stopSpinner.succeed(`Proceso en puerto ${c.port} detenido`);
583
+ logStep(`Puerto ${c.port} liberado`, 'success');
584
+ }
585
+ else {
586
+ stopSpinner.warn(`No se encontró proceso en puerto ${c.port}`);
587
+ }
588
+ }
589
+ catch {
590
+ stopSpinner.fail(`No se pudo detener el proceso en puerto ${c.port}`);
591
+ logger_1.log.dim(` Intenta manualmente: sudo lsof -i :${c.port} -t | xargs kill -9`);
592
+ }
593
+ }
594
+ logger_1.log.blank();
277
595
  }
596
+ if (portAction === 'alternate') {
597
+ const portMap = { 5432: 5433, 8080: 8081, 3000: 3001 };
598
+ // Actualizar .env
599
+ const envPath = path_1.default.join(targetDir, '.env');
600
+ if (fs_1.default.existsSync(envPath)) {
601
+ let envContent = fs_1.default.readFileSync(envPath, 'utf-8');
602
+ for (const [oldPort, newPort] of Object.entries(portMap)) {
603
+ const oldPortNum = parseInt(oldPort);
604
+ if (conflicts.some(c => c.port === oldPortNum)) {
605
+ envContent = envContent.replace(new RegExp(`:${oldPortNum}\\b`, 'g'), `:${newPort}`);
606
+ envContent = envContent.replace(new RegExp(`PORT=${oldPortNum}`, 'g'), `PORT=${newPort}`);
607
+ logger_1.log.info(`Puerto ${oldPort} → ${newPort}`);
608
+ }
609
+ }
610
+ fs_1.default.writeFileSync(envPath, envContent);
611
+ logStep('Puertos alternativos configurados en .env', 'success');
612
+ }
613
+ // Actualizar docker-compose files
614
+ const composeFiles = [
615
+ 'docker-compose.yml',
616
+ 'docker-compose.prod.yml',
617
+ ];
618
+ for (const composeFile of composeFiles) {
619
+ const composePath = path_1.default.join(targetDir, composeFile);
620
+ if (fs_1.default.existsSync(composePath)) {
621
+ let composeContent = fs_1.default.readFileSync(composePath, 'utf-8');
622
+ for (const [oldPort, newPort] of Object.entries(portMap)) {
623
+ const oldPortNum = parseInt(oldPort);
624
+ if (conflicts.some(c => c.port === oldPortNum)) {
625
+ composeContent = composeContent.replace(new RegExp(`"([^"]*):${oldPortNum}"`, 'g'), `$1:${newPort}"`);
626
+ composeContent = composeContent.replace(new RegExp(`- "${oldPortNum}:${oldPortNum}"`, 'g'), `- "${newPort}:${oldPortNum}"`);
627
+ composeContent = composeContent.replace(new RegExp(`- "0\\.0\\.0\\.0:${oldPortNum}:`, 'g'), `- "0.0.0.0:${newPort}:`);
628
+ composeContent = composeContent.replace(new RegExp(`- "127\\.0\\.0\\.1:${oldPortNum}:`, 'g'), `- "127.0.0.1:${newPort}:`);
629
+ }
630
+ }
631
+ fs_1.default.writeFileSync(composePath, composeContent);
632
+ logStep(`Puertos actualizados en ${composeFile}`, 'success');
633
+ }
634
+ }
635
+ logger_1.log.blank();
636
+ }
637
+ }
638
+ }
639
+ // 9. Monitoreo — selección granular de servicios
640
+ let includeMonitoring = false;
641
+ let monitoringServices = [];
642
+ if (installMode === 'full' || installMode === 'docker') {
643
+ if (opts.withAnalytics) {
644
+ includeMonitoring = true;
645
+ monitoringServices = (0, monitoring_1.fullMonitoringServices)();
646
+ }
647
+ else if (opts.monitoring) {
648
+ includeMonitoring = true;
649
+ monitoringServices = (0, monitoring_1.basicMonitoringServices)();
650
+ }
651
+ else if (!nonInteractive) {
652
+ const { monitoring } = await inquirer_1.default.prompt([
653
+ {
654
+ type: 'confirm',
655
+ name: 'monitoring',
656
+ message: 'Incluir stack de monitoreo (Grafana, Prometheus, pgAdmin, Portainer…)?',
657
+ default: false,
658
+ },
659
+ ]);
660
+ if (monitoring) {
661
+ const { services } = await inquirer_1.default.prompt([
662
+ {
663
+ type: 'checkbox',
664
+ name: 'services',
665
+ message: 'Servicios de monitoreo a instalar:',
666
+ choices: (0, monitoring_1.monitoringChoices)(),
667
+ },
668
+ ]);
669
+ monitoringServices = services;
670
+ includeMonitoring = monitoringServices.length > 0;
671
+ }
672
+ }
673
+ }
674
+ // Generar docker-compose.monitoring.yml + configs con los servicios elegidos
675
+ if (includeMonitoring && monitoringServices.length > 0) {
676
+ const monSpinner = (0, ora_1.default)('Generando stack de monitoreo...').start();
677
+ const serviceSet = new Set(monitoringServices);
678
+ fs_1.default.writeFileSync(path_1.default.join(targetDir, 'docker-compose.monitoring.yml'), (0, monitoring_1.generateMonitoringCompose)(serviceSet));
679
+ (0, monitoring_1.writeMonitoringConfigs)(targetDir, serviceSet);
680
+ const envFile = path_1.default.join(targetDir, '.env');
681
+ if (fs_1.default.existsSync(envFile)) {
682
+ fs_1.default.writeFileSync(envFile, (0, env_1.applyEnvOverrides)(fs_1.default.readFileSync(envFile, 'utf-8'), {
683
+ MONITORING_SERVICES: monitoringServices.join(','),
684
+ }));
685
+ }
686
+ monSpinner.succeed(`Monitoreo: ${monitoringServices.join(', ')}`);
687
+ logStep(`Monitoreo configurado: ${monitoringServices.join(', ')}`, 'success');
688
+ }
689
+ else {
690
+ includeMonitoring = false;
691
+ }
692
+ // Preguntar por servicio systemd (siempre, si es Linux)
693
+ if (!nonInteractive && (0, helpers_1.isLinux)() && (installMode === 'full' || installMode === 'docker' || installMode === 'minimal')) {
694
+ const { installService } = await inquirer_1.default.prompt([
695
+ {
696
+ type: 'confirm',
697
+ name: 'installService',
698
+ message: 'Instalar como servicio systemd (auto-start al iniciar el sistema)?',
699
+ default: false,
700
+ },
701
+ ]);
702
+ if (installService)
703
+ opts.service = true;
704
+ }
705
+ // 10. Ejecutar instalación según modo
706
+ const dockerCmd = (0, helpers_1.getDockerComposeCommand)();
707
+ logStep(`Modo de instalación: ${installMode}`, 'info');
708
+ // Verificar conflictos de puertos antes de levantar contenedores
709
+ if (installMode === 'full' || installMode === 'docker' || installMode === 'minimal') {
710
+ const commonPorts = [
711
+ { port: 5432, name: 'PostgreSQL', container: 'db' },
712
+ { port: 8080, name: 'Web', container: 'web' },
713
+ { port: 3000, name: 'API Server', container: 'server' },
714
+ ];
715
+ const conflicts = commonPorts.filter(p => {
716
+ try {
717
+ const output = (0, child_process_1.execSync)(`lsof -i :${p.port} -sTCP:LISTEN 2>/dev/null | grep -v 'docker-pr' | grep -v 'COMMAND' || true`, { stdio: 'pipe' }).toString().trim();
718
+ return output.length > 0;
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ });
724
+ if (conflicts.length > 0 && !nonInteractive) {
725
+ logger_1.log.blank();
726
+ logger_1.log.warn('Puertos en conflicto detectados:');
727
+ for (const c of conflicts) {
728
+ let processInfo = '';
729
+ try {
730
+ const pid = (0, child_process_1.execSync)(`lsof -i :${c.port} -sTCP:LISTEN -t 2>/dev/null | grep -v 'docker-pr' | head -1 || true`, { stdio: 'pipe' }).toString().trim();
731
+ if (pid) {
732
+ const procName = (0, child_process_1.execSync)(`ps -p ${pid} -o comm= 2>/dev/null || echo 'desconocido'`, { stdio: 'pipe' }).toString().trim();
733
+ processInfo = ` (${procName})`;
734
+ }
735
+ }
736
+ catch { }
737
+ logger_1.log.warn(` Puerto ${c.port} (${c.name}): ocupado${processInfo}`);
738
+ }
739
+ logger_1.log.blank();
740
+ const { portAction } = await inquirer_1.default.prompt([
741
+ {
742
+ type: 'list',
743
+ name: 'portAction',
744
+ message: '¿Cómo resolver el conflicto?',
745
+ choices: [
746
+ { name: 'Detener el proceso que ocupa el puerto', value: 'stop' },
747
+ { name: 'Usar puertos alternativos (5433, 8081, 3001)', value: 'alternate' },
748
+ { name: 'Continuar igual (puede fallar)', value: 'continue' },
749
+ { name: 'Cancelar instalación', value: 'cancel' },
750
+ ],
751
+ },
752
+ ]);
753
+ if (portAction === 'cancel') {
754
+ logger_1.log.info('Instalación cancelada');
755
+ writeInstallLog(targetDir);
756
+ return;
757
+ }
758
+ if (portAction === 'stop') {
759
+ for (const c of conflicts) {
760
+ const stopSpinner = (0, ora_1.default)(`Deteniendo proceso en puerto ${c.port}...`).start();
761
+ try {
762
+ const pid = (0, child_process_1.execSync)(`lsof -i :${c.port} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe' }).toString().trim();
763
+ if (pid) {
764
+ (0, child_process_1.execSync)(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'pipe' });
765
+ stopSpinner.succeed(`Proceso en puerto ${c.port} detenido`);
766
+ logStep(`Puerto ${c.port} liberado`, 'success');
767
+ }
768
+ else {
769
+ stopSpinner.warn(`No se encontró proceso en puerto ${c.port}`);
770
+ }
771
+ }
772
+ catch {
773
+ stopSpinner.fail(`No se pudo detener el proceso en puerto ${c.port}`);
774
+ logger_1.log.dim(` Intenta manualmente: sudo lsof -i :${c.port} -t | xargs kill -9`);
775
+ }
776
+ }
777
+ logger_1.log.blank();
778
+ }
779
+ if (portAction === 'alternate') {
780
+ const portMap = { 5432: 5433, 8080: 8081, 3000: 3001 };
781
+ // Actualizar .env
782
+ const envPath = path_1.default.join(targetDir, '.env');
783
+ if (fs_1.default.existsSync(envPath)) {
784
+ let envContent = fs_1.default.readFileSync(envPath, 'utf-8');
785
+ for (const [oldPort, newPort] of Object.entries(portMap)) {
786
+ const oldPortNum = parseInt(oldPort);
787
+ if (conflicts.some(c => c.port === oldPortNum)) {
788
+ envContent = envContent.replace(new RegExp(`:${oldPortNum}\\b`, 'g'), `:${newPort}`);
789
+ envContent = envContent.replace(new RegExp(`PORT=${oldPortNum}`, 'g'), `PORT=${newPort}`);
790
+ logger_1.log.info(`Puerto ${oldPort} → ${newPort}`);
791
+ }
792
+ }
793
+ fs_1.default.writeFileSync(envPath, envContent);
794
+ logStep('Puertos alternativos configurados en .env', 'success');
795
+ }
796
+ // Actualizar docker-compose files
797
+ const composeFiles = [
798
+ 'docker-compose.yml',
799
+ 'docker-compose.prod.yml',
800
+ ];
801
+ for (const composeFile of composeFiles) {
802
+ const composePath = path_1.default.join(targetDir, composeFile);
803
+ if (fs_1.default.existsSync(composePath)) {
804
+ let composeContent = fs_1.default.readFileSync(composePath, 'utf-8');
805
+ for (const [oldPort, newPort] of Object.entries(portMap)) {
806
+ const oldPortNum = parseInt(oldPort);
807
+ if (conflicts.some(c => c.port === oldPortNum)) {
808
+ // Reemplazar puertos en mappings de Docker "host:container"
809
+ composeContent = composeContent.replace(new RegExp(`"([^"]*):${oldPortNum}"`, 'g'), `$1:${newPort}"`);
810
+ composeContent = composeContent.replace(new RegExp(`- "${oldPortNum}:${oldPortNum}"`, 'g'), `- "${newPort}:${oldPortNum}"`);
811
+ composeContent = composeContent.replace(new RegExp(`- "0\\.0\\.0\\.0:${oldPortNum}:`, 'g'), `- "0.0.0.0:${newPort}:`);
812
+ composeContent = composeContent.replace(new RegExp(`- "127\\.0\\.0\\.1:${oldPortNum}:`, 'g'), `- "127.0.0.1:${newPort}:`);
813
+ }
814
+ }
815
+ fs_1.default.writeFileSync(composePath, composeContent);
816
+ logStep(`Puertos actualizados en ${composeFile}`, 'success');
817
+ }
818
+ }
819
+ logger_1.log.blank();
820
+ }
821
+ }
822
+ }
823
+ if (installMode === 'full' || installMode === 'docker' || installMode === 'minimal') {
824
+ if (installMode === 'full' || installMode === 'docker') {
825
+ const dockerSpinner = (0, ora_1.default)('Construyendo contenedores Docker...').start();
826
+ logStep('Iniciando build de Docker', 'info');
827
+ try {
828
+ (0, child_process_1.execSync)(`${dockerCmd} build`, { cwd: targetDir, stdio: 'pipe', timeout: 300000 });
829
+ dockerSpinner.succeed('Contenedores construidos');
830
+ logStep('Build de Docker completado', 'success');
831
+ }
832
+ catch (err) {
833
+ dockerSpinner.fail('Error en build: ' + err.message);
834
+ logStep(`Error en build de Docker: ${err.message}`, 'error');
835
+ // Detectar error de permisos de Docker
836
+ if (err.message?.includes('permission denied') && err.message?.includes('docker.sock')) {
837
+ logger_1.log.blank();
838
+ logger_1.log.warn('Error de permisos de Docker detectado');
839
+ logger_1.log.dim(' Tu usuario no tiene acceso al socket de Docker');
840
+ logger_1.log.blank();
841
+ if (!nonInteractive) {
842
+ const { fixPermissions } = await inquirer_1.default.prompt([
843
+ {
844
+ type: 'confirm',
845
+ name: 'fixPermissions',
846
+ message: '¿Agregar tu usuario al grupo docker para arreglarlo? (requiere sudo)',
847
+ default: true,
848
+ },
849
+ ]);
850
+ if (fixPermissions) {
851
+ const fixSpinner = (0, ora_1.default)('Agregando usuario al grupo docker...').start();
852
+ logStep('Arreglando permisos de Docker', 'info');
853
+ try {
854
+ const user = process.env.USER || process.env.USERNAME || 'root';
855
+ (0, child_process_1.execSync)(`sudo usermod -aG docker ${user}`, { stdio: 'pipe' });
856
+ fixSpinner.succeed('Usuario agregado al grupo docker');
857
+ logStep('Permisos de Docker arreglados', 'success');
858
+ logger_1.log.blank();
859
+ logger_1.log.info('Los cambios se aplican en la próxima sesión');
860
+ logger_1.log.dim(` Ejecuta: newgrp docker`);
861
+ logger_1.log.dim(` O cierra sesión y vuelve a entrar`);
862
+ logger_1.log.blank();
863
+ const { retryBuild } = await inquirer_1.default.prompt([
864
+ {
865
+ type: 'confirm',
866
+ name: 'retryBuild',
867
+ message: '¿Reintentar el build ahora?',
868
+ default: false,
869
+ },
870
+ ]);
871
+ if (retryBuild) {
872
+ const retrySpinner = (0, ora_1.default)('Reintentando build...').start();
873
+ try {
874
+ (0, child_process_1.execSync)(`${dockerCmd} build`, { cwd: targetDir, stdio: 'pipe', timeout: 300000 });
875
+ retrySpinner.succeed('Build completado');
876
+ logStep('Build completado tras arreglar permisos', 'success');
877
+ }
878
+ catch {
879
+ retrySpinner.warn('Aún hay error de permisos');
880
+ logStep('Reintento de build fallido', 'warn');
881
+ }
882
+ }
883
+ }
884
+ catch (fixErr) {
885
+ fixSpinner.fail('No se pudieron arreglar los permisos');
886
+ logStep(`Error arreglando permisos: ${fixErr.message}`, 'error');
887
+ logger_1.log.dim(' Ejecuta manualmente: sudo usermod -aG docker $USER');
888
+ }
889
+ }
890
+ }
891
+ else {
892
+ logger_1.log.dim(' Ejecuta: sudo usermod -aG docker $USER');
893
+ }
894
+ }
895
+ logger_1.log.blank();
896
+ const { continueWithoutBuild } = await inquirer_1.default.prompt([
897
+ {
898
+ type: 'confirm',
899
+ name: 'continueWithoutBuild',
900
+ message: 'El build falló. ¿Continuar sin build y solo levantar contenedores existentes?',
901
+ default: false,
902
+ },
903
+ ]);
904
+ if (!continueWithoutBuild) {
905
+ logger_1.log.info('Instalación cancelada');
906
+ writeInstallLog(targetDir);
907
+ return;
908
+ }
909
+ }
910
+ }
911
+ const upSpinner = (0, ora_1.default)('Levantando servicios...').start();
912
+ logStep('Levantando servicios Docker', 'info');
913
+ let composeFiles = '-f docker-compose.yml';
914
+ if (fs_1.default.existsSync(path_1.default.join(targetDir, 'docker-compose.prod.yml'))) {
915
+ composeFiles = '-f docker-compose.prod.yml';
916
+ }
917
+ if (includeMonitoring) {
918
+ const monPath = path_1.default.join(targetDir, 'docker-compose.monitoring.yml');
919
+ if (fs_1.default.existsSync(monPath)) {
920
+ composeFiles += ` -f docker-compose.monitoring.yml`;
921
+ }
922
+ }
923
+ try {
924
+ (0, child_process_1.execSync)(`${dockerCmd} ${composeFiles} up -d`, { cwd: targetDir, stdio: 'pipe', timeout: 120000 });
925
+ upSpinner.succeed('Servicios levantados');
926
+ logStep('Servicios Docker levantados', 'success');
278
927
  }
279
928
  catch (err) {
280
- dockerSpinner.fail('Error Docker: ' + err.message);
281
- logger_1.log.dim(` Ejecuta manualmente: cd ${targetDir} && docker compose up -d`);
929
+ upSpinner.fail('Error: ' + err.message);
930
+ logStep(`Error levantando servicios: ${err.message}`, 'error');
931
+ // Detectar error de permisos de Docker
932
+ if (err.message?.includes('permission denied') && err.message?.includes('docker.sock')) {
933
+ logger_1.log.blank();
934
+ logger_1.log.warn('Error de permisos de Docker detectado');
935
+ logger_1.log.dim(' Tu usuario no tiene acceso al socket de Docker');
936
+ logger_1.log.blank();
937
+ if (!nonInteractive) {
938
+ const { fixPermissions } = await inquirer_1.default.prompt([
939
+ {
940
+ type: 'confirm',
941
+ name: 'fixPermissions',
942
+ message: '¿Agregar tu usuario al grupo docker para arreglarlo? (requiere sudo)',
943
+ default: true,
944
+ },
945
+ ]);
946
+ if (fixPermissions) {
947
+ const fixSpinner = (0, ora_1.default)('Agregando usuario al grupo docker...').start();
948
+ logStep('Arreglando permisos de Docker', 'info');
949
+ try {
950
+ const user = process.env.USER || process.env.USERNAME || 'root';
951
+ (0, child_process_1.execSync)(`sudo usermod -aG docker ${user}`, { stdio: 'pipe' });
952
+ fixSpinner.succeed('Usuario agregado al grupo docker');
953
+ logStep('Permisos de Docker arreglados', 'success');
954
+ logger_1.log.blank();
955
+ logger_1.log.info('Los cambios se aplican en la próxima sesión');
956
+ logger_1.log.dim(` Ejecuta: newgrp docker`);
957
+ logger_1.log.dim(` O cierra sesión y vuelve a entrar`);
958
+ logger_1.log.blank();
959
+ const { retryNow } = await inquirer_1.default.prompt([
960
+ {
961
+ type: 'confirm',
962
+ name: 'retryNow',
963
+ message: '¿Intentar levantar los servicios ahora? (puede fallar hasta que apliquen los permisos)',
964
+ default: false,
965
+ },
966
+ ]);
967
+ if (retryNow) {
968
+ const retrySpinner = (0, ora_1.default)('Reintentando...').start();
969
+ try {
970
+ (0, child_process_1.execSync)(`${dockerCmd} ${composeFiles} up -d`, { cwd: targetDir, stdio: 'pipe', timeout: 120000 });
971
+ retrySpinner.succeed('Servicios levantados');
972
+ logStep('Servicios levantados tras arreglar permisos', 'success');
973
+ }
974
+ catch (retryErr) {
975
+ retrySpinner.warn('Aún hay error de permisos');
976
+ logStep('Reintento fallido, permisos no aplicados aún', 'warn');
977
+ logger_1.log.dim(' Cierra sesión y vuelve a entrar, luego ejecuta:');
978
+ logger_1.log.dim(` cd ${targetDir} && ${dockerCmd} up -d`);
979
+ }
980
+ }
981
+ }
982
+ catch (fixErr) {
983
+ fixSpinner.fail('No se pudieron arreglar los permisos');
984
+ logStep(`Error arreglando permisos: ${fixErr.message}`, 'error');
985
+ logger_1.log.dim(' Ejecuta manualmente: sudo usermod -aG docker $USER');
986
+ }
987
+ }
988
+ }
989
+ else {
990
+ logger_1.log.dim(' Ejecuta: sudo usermod -aG docker $USER');
991
+ logger_1.log.dim(' Luego cierra sesión y vuelve a entrar');
992
+ }
993
+ }
994
+ else if (err.message?.includes('Bind for') && err.message?.includes('port is already allocated')) {
995
+ // Detectar error de puerto ocupado
996
+ logger_1.log.blank();
997
+ logger_1.log.warn('Puerto ocupado detectado');
998
+ const portMatch = err.message.match(/Bind for [\d.]+:(\d+)/);
999
+ const occupiedPort = portMatch ? portMatch[1] : 'desconocido';
1000
+ logger_1.log.warn(` Puerto ${occupiedPort} ya está en uso`);
1001
+ logger_1.log.blank();
1002
+ if (!nonInteractive) {
1003
+ const { portAction } = await inquirer_1.default.prompt([
1004
+ {
1005
+ type: 'list',
1006
+ name: 'portAction',
1007
+ message: '¿Cómo resolverlo?',
1008
+ choices: [
1009
+ { name: 'Detener el proceso que ocupa el puerto', value: 'stop' },
1010
+ { name: 'Cambiar puerto en .env y reintentar', value: 'change' },
1011
+ { name: 'Cancelar', value: 'cancel' },
1012
+ ],
1013
+ },
1014
+ ]);
1015
+ if (portAction === 'cancel') {
1016
+ logger_1.log.info('Instalación cancelada');
1017
+ writeInstallLog(targetDir);
1018
+ return;
1019
+ }
1020
+ if (portAction === 'stop') {
1021
+ const stopSpinner = (0, ora_1.default)(`Deteniendo proceso en puerto ${occupiedPort}...`).start();
1022
+ try {
1023
+ const pid = (0, child_process_1.execSync)(`lsof -i :${occupiedPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe' }).toString().trim();
1024
+ if (pid) {
1025
+ (0, child_process_1.execSync)(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'pipe' });
1026
+ stopSpinner.succeed(`Proceso detenido`);
1027
+ logStep(`Puerto ${occupiedPort} liberado`, 'success');
1028
+ const { retry } = await inquirer_1.default.prompt([
1029
+ { type: 'confirm', name: 'retry', message: '¿Reintentar levantar servicios?', default: true },
1030
+ ]);
1031
+ if (retry) {
1032
+ const retrySpinner = (0, ora_1.default)('Reintentando...').start();
1033
+ try {
1034
+ (0, child_process_1.execSync)(`${dockerCmd} ${composeFiles} up -d`, { cwd: targetDir, stdio: 'pipe', timeout: 120000 });
1035
+ retrySpinner.succeed('Servicios levantados');
1036
+ logStep('Servicios levantados tras liberar puerto', 'success');
1037
+ }
1038
+ catch {
1039
+ retrySpinner.fail('Aún hay error');
1040
+ }
1041
+ }
1042
+ }
1043
+ else {
1044
+ stopSpinner.warn('No se encontró el proceso');
1045
+ }
1046
+ }
1047
+ catch {
1048
+ stopSpinner.fail('No se pudo detener el proceso');
1049
+ logger_1.log.dim(` Manual: sudo lsof -i :${occupiedPort} -t | xargs kill -9`);
1050
+ }
1051
+ }
1052
+ if (portAction === 'change') {
1053
+ const envPath = path_1.default.join(targetDir, '.env');
1054
+ if (fs_1.default.existsSync(envPath)) {
1055
+ const { newPort } = await inquirer_1.default.prompt([
1056
+ { type: 'input', name: 'newPort', message: `Nuevo puerto para reemplazar ${occupiedPort}:`, default: String(parseInt(occupiedPort) + 1) },
1057
+ ]);
1058
+ let envContent = fs_1.default.readFileSync(envPath, 'utf-8');
1059
+ envContent = envContent.replace(new RegExp(`:${occupiedPort}\\b`, 'g'), `:${newPort}`);
1060
+ envContent = envContent.replace(new RegExp(`PORT=${occupiedPort}`, 'g'), `PORT=${newPort}`);
1061
+ fs_1.default.writeFileSync(envPath, envContent);
1062
+ logger_1.log.success(`Puerto cambiado a ${newPort} en .env`);
1063
+ logStep(`Puerto ${occupiedPort} → ${newPort}`, 'success');
1064
+ const { retry } = await inquirer_1.default.prompt([
1065
+ { type: 'confirm', name: 'retry', message: '¿Reintentar levantar servicios?', default: true },
1066
+ ]);
1067
+ if (retry) {
1068
+ const retrySpinner = (0, ora_1.default)('Reintentando...').start();
1069
+ try {
1070
+ (0, child_process_1.execSync)(`${dockerCmd} ${composeFiles} up -d`, { cwd: targetDir, stdio: 'pipe', timeout: 120000 });
1071
+ retrySpinner.succeed('Servicios levantados');
1072
+ }
1073
+ catch {
1074
+ retrySpinner.fail('Aún hay error');
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ }
1080
+ else {
1081
+ logger_1.log.dim(` Puerto ${occupiedPort} ocupado. Cambia el puerto en .env o detén el proceso.`);
1082
+ }
1083
+ }
1084
+ else {
1085
+ logger_1.log.dim(` cd ${targetDir} && ${dockerCmd} up -d`);
1086
+ }
1087
+ }
1088
+ // Health checks
1089
+ if (opts.healthcheck !== false && (installMode === 'full' || installMode === 'docker')) {
1090
+ logger_1.log.blank();
1091
+ const healthSpinner = (0, ora_1.default)('Verificando servicios...').start();
1092
+ logStep('Ejecutando health checks', 'info');
1093
+ const webHealthy = await (0, helpers_1.waitForService)('http://localhost:8080', 20, 3000);
1094
+ const apiHealthy = await (0, helpers_1.waitForService)('http://localhost:3000/api/health', 15, 3000);
1095
+ if (webHealthy && apiHealthy) {
1096
+ healthSpinner.succeed('Servicios operativos');
1097
+ logStep('Health checks: Web OK, API OK', 'success');
1098
+ }
1099
+ else {
1100
+ healthSpinner.warn('Algunos servicios tardan en iniciar');
1101
+ logStep(`Health checks: Web=${webHealthy ? 'OK' : 'FAIL'}, API=${apiHealthy ? 'OK' : 'FAIL'}`, 'warn');
1102
+ logger_1.log.dim(' Verifica con: docker compose ps');
1103
+ }
1104
+ }
1105
+ // Setup DB si es full
1106
+ if (installMode === 'full') {
1107
+ logger_1.log.blank();
1108
+ logger_1.log.info('Ejecutando configuración inicial de base de datos...');
1109
+ logStep('Configurando base de datos', 'info');
1110
+ try {
1111
+ (0, child_process_1.execSync)('docker compose exec -T server sh -c "npm run db:push || true"', {
1112
+ cwd: targetDir,
1113
+ stdio: 'pipe',
1114
+ timeout: 60000,
1115
+ });
1116
+ logger_1.log.success('Base de datos configurada');
1117
+ logStep('Base de datos configurada', 'success');
1118
+ }
1119
+ catch {
1120
+ logger_1.log.dim(' Ejecuta manualmente: openfactu setup');
1121
+ logStep('Configuración de BD omitida', 'warn');
1122
+ }
1123
+ }
1124
+ }
1125
+ // 11. Instalar como servicio si se pidió
1126
+ if (opts.service) {
1127
+ logger_1.log.blank();
1128
+ logger_1.log.info('Instalando servicio systemd...');
1129
+ try {
1130
+ const serviceName = 'openfactu';
1131
+ const unitPath = `/etc/systemd/system/${serviceName}.service`;
1132
+ const serviceExists = fs_1.default.existsSync(unitPath);
1133
+ if (serviceExists && !nonInteractive) {
1134
+ const { overwriteService } = await inquirer_1.default.prompt([
1135
+ {
1136
+ type: 'confirm',
1137
+ name: 'overwriteService',
1138
+ message: `El servicio ${serviceName} ya existe. ¿Sobrescribir?`,
1139
+ default: false,
1140
+ },
1141
+ ]);
1142
+ if (!overwriteService) {
1143
+ logger_1.log.info('Servicio no modificado');
1144
+ }
1145
+ else {
1146
+ installService(targetDir, dockerCmd, serviceName, unitPath);
1147
+ }
1148
+ }
1149
+ else if (serviceExists && nonInteractive) {
1150
+ logger_1.log.warn(`El servicio ${serviceName} ya existe, sobrescribiendo`);
1151
+ installService(targetDir, dockerCmd, serviceName, unitPath);
1152
+ }
1153
+ else {
1154
+ installService(targetDir, dockerCmd, serviceName, unitPath);
1155
+ }
1156
+ }
1157
+ catch (err) {
1158
+ logger_1.log.warn('No se pudo instalar el servicio: ' + err.message);
282
1159
  }
283
1160
  }
284
- // 7. Resumen
1161
+ // 12. Escribir log de instalacion
1162
+ writeInstallLog(targetDir);
1163
+ // 12. Resumen
285
1164
  const installedPkg = path_1.default.join(targetDir, 'package.json');
286
1165
  let installedVersion = '?';
287
1166
  if (fs_1.default.existsSync(installedPkg)) {
@@ -304,20 +1183,35 @@ function registerInstallCommand(program) {
304
1183
  console.log(` ${chalk_1.default.dim('Commit:')} ${chalk_1.default.cyan(installedCommit)}`);
305
1184
  console.log(` ${chalk_1.default.dim('Directorio:')} ${chalk_1.default.white(targetDir)}`);
306
1185
  console.log(` ${chalk_1.default.dim('Modo:')} ${chalk_1.default.white(installMode)}`);
1186
+ if (includeMonitoring)
1187
+ console.log(` ${chalk_1.default.dim('Monitoreo:')} ${chalk_1.default.green(monitoringServices.join(', '))}`);
307
1188
  logger_1.log.blank();
1189
+ logStep('Instalación completada exitosamente', 'success');
1190
+ logStep(`Versión: ${installedVersion}, Ref: ${ref}, Commit: ${installedCommit}`, 'info');
308
1191
  logger_1.log.dim(' Próximos pasos:');
309
1192
  logger_1.log.dim(` cd ${targetDir}`);
310
- if (installMode === 'docker') {
1193
+ if (installMode !== 'download') {
311
1194
  logger_1.log.dim(' openfactu deploy — Configurar acceso externo');
1195
+ logger_1.log.dim(' openfactu setup — Configurar base de datos');
312
1196
  logger_1.log.dim(' openfactu deploy:status — Ver estado de servicios');
1197
+ if (includeMonitoring) {
1198
+ logger_1.log.dim(' openfactu monitoring — Configurar monitoreo');
1199
+ }
313
1200
  }
314
1201
  else {
315
- logger_1.log.dim(' docker compose up -d — Levantar con Docker');
1202
+ logger_1.log.dim(` ${dockerCmd} up -d — Levantar con Docker`);
316
1203
  logger_1.log.dim(' openfactu deploy — Configurar acceso externo');
317
1204
  }
318
1205
  logger_1.log.blank();
1206
+ logger_1.log.dim(` Log de instalación: ${chalk_1.default.dim(path_1.default.join(targetDir, '.openfactu', 'install.log'))}`);
1207
+ logger_1.log.blank();
319
1208
  }
320
1209
  catch (err) {
1210
+ logStep(`Error fatal: ${err.message}`, 'error');
1211
+ try {
1212
+ writeInstallLog(targetDir || os_1.default.homedir());
1213
+ }
1214
+ catch { }
321
1215
  logger_1.log.error(err.message);
322
1216
  process.exitCode = 1;
323
1217
  }