@openfactu/cli 0.0.6 → 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,7 @@ const path_1 = __importDefault(require("path"));
13
13
  const os_1 = __importDefault(require("os"));
14
14
  const logger_1 = require("../utils/logger");
15
15
  const paths_1 = require("../utils/paths");
16
+ const helpers_1 = require("../utils/helpers");
16
17
  function getLocalIPs() {
17
18
  const interfaces = os_1.default.networkInterfaces();
18
19
  const ips = [];
@@ -27,6 +28,26 @@ function getLocalIPs() {
27
28
  }
28
29
  return ips;
29
30
  }
31
+ function checkPortInUse(port) {
32
+ try {
33
+ const output = (0, child_process_1.execSync)(`lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null | grep -v 'docker-pr' || true`, {
34
+ stdio: 'pipe',
35
+ }).toString().trim();
36
+ if (output) {
37
+ const pid = output.split('\n')[0].trim();
38
+ let processName = 'desconocido';
39
+ try {
40
+ processName = (0, child_process_1.execSync)(`ps -p ${pid} -o comm= 2>/dev/null || echo 'desconocido'`, { stdio: 'pipe' }).toString().trim();
41
+ }
42
+ catch { }
43
+ return { inUse: true, process: `${processName} (PID: ${pid})` };
44
+ }
45
+ return { inUse: false };
46
+ }
47
+ catch {
48
+ return { inUse: false };
49
+ }
50
+ }
30
51
  function readEnv(envPath) {
31
52
  const env = {};
32
53
  if (!fs_1.default.existsSync(envPath))
@@ -54,7 +75,8 @@ function registerDeployCommand(program) {
54
75
  program
55
76
  .command('deploy')
56
77
  .description('Configura OpenFactu para producción (acceso externo)')
57
- .action(async () => {
78
+ .option('--with-monitoring', 'Incluir stack de monitoreo (Grafana, Prometheus, etc.)')
79
+ .action(async (opts) => {
58
80
  console.log();
59
81
  console.log(chalk_1.default.bold.white(' OpenFactu — Configurar Despliegue'));
60
82
  console.log(chalk_1.default.dim(' ────────────────────────────────────'));
@@ -82,6 +104,7 @@ function registerDeployCommand(program) {
82
104
  let host = 'localhost';
83
105
  let serverPort = '3000';
84
106
  let webPort = '8080';
107
+ let dbPort = '5432';
85
108
  let useSSL = false;
86
109
  if (mode === 'lan') {
87
110
  const ipChoices = localIPs.map((ip) => ({ name: ip, value: ip }));
@@ -103,6 +126,15 @@ function registerDeployCommand(program) {
103
126
  else {
104
127
  host = selectedIP;
105
128
  }
129
+ const { ssl } = await inquirer_1.default.prompt([
130
+ {
131
+ type: 'confirm',
132
+ name: 'ssl',
133
+ message: '¿Usar HTTPS? (certificado auto-firmado para red local)',
134
+ default: false,
135
+ },
136
+ ]);
137
+ useSSL = ssl;
106
138
  }
107
139
  else if (mode === 'public') {
108
140
  const { domain } = await inquirer_1.default.prompt([
@@ -123,12 +155,56 @@ function registerDeployCommand(program) {
123
155
  ]);
124
156
  useSSL = ssl;
125
157
  }
158
+ // Verificar conflictos de puertos si se usa SSL
159
+ let httpPort = '80';
160
+ let httpsPort = '443';
161
+ if (useSSL) {
162
+ const port80 = checkPortInUse(80);
163
+ const port443 = checkPortInUse(443);
164
+ if (port80.inUse || port443.inUse) {
165
+ logger_1.log.blank();
166
+ logger_1.log.warn('Puertos en conflicto detectados:');
167
+ if (port80.inUse)
168
+ logger_1.log.warn(` Puerto 80: ocupado por ${port80.process}`);
169
+ if (port443.inUse)
170
+ logger_1.log.warn(` Puerto 443: ocupado por ${port443.process}`);
171
+ logger_1.log.blank();
172
+ const { portAction } = await inquirer_1.default.prompt([
173
+ {
174
+ type: 'list',
175
+ name: 'portAction',
176
+ message: '¿Cómo resolver el conflicto?',
177
+ choices: [
178
+ { name: 'Usar puertos alternativos (8080/8443)', value: 'alternate' },
179
+ { name: 'Especificar puertos personalizados', value: 'custom' },
180
+ { name: 'Continuar con 80/443 (puede fallar)', value: 'continue' },
181
+ { name: 'Desactivar HTTPS', value: 'nossl' },
182
+ ],
183
+ },
184
+ ]);
185
+ if (portAction === 'nossl') {
186
+ useSSL = false;
187
+ }
188
+ else if (portAction === 'alternate') {
189
+ httpPort = '8080';
190
+ httpsPort = '8443';
191
+ }
192
+ else if (portAction === 'custom') {
193
+ const { customHttp, customHttps } = await inquirer_1.default.prompt([
194
+ { type: 'input', name: 'customHttp', message: 'Puerto HTTP alternativo:', default: '8080' },
195
+ { type: 'input', name: 'customHttps', message: 'Puerto HTTPS alternativo:', default: '8443' },
196
+ ]);
197
+ httpPort = customHttp;
198
+ httpsPort = customHttps;
199
+ }
200
+ }
201
+ }
126
202
  // Puertos
127
203
  const { ports } = await inquirer_1.default.prompt([
128
204
  {
129
205
  type: 'confirm',
130
206
  name: 'ports',
131
- message: `¿Usar puertos por defecto? (web: 8080, api: 3000)`,
207
+ message: `¿Usar puertos por defecto? (web: 8080, api: 3000, db: 5432)`,
132
208
  default: true,
133
209
  },
134
210
  ]);
@@ -136,32 +212,42 @@ function registerDeployCommand(program) {
136
212
  const answers = await inquirer_1.default.prompt([
137
213
  { type: 'input', name: 'webPort', message: 'Puerto web:', default: '8080' },
138
214
  { type: 'input', name: 'serverPort', message: 'Puerto API:', default: '3000' },
215
+ { type: 'input', name: 'dbPort', message: 'Puerto BD (host):', default: '5432' },
139
216
  ]);
140
217
  webPort = answers.webPort;
141
218
  serverPort = answers.serverPort;
219
+ dbPort = answers.dbPort;
142
220
  }
143
- // Password de BD
144
- const { dbPassword } = await inquirer_1.default.prompt([
145
- {
146
- type: 'input',
147
- name: 'dbPassword',
148
- message: 'Password de PostgreSQL:',
149
- default: 'openfactu_pass',
150
- },
151
- ]);
221
+ // Password de BD: se reutiliza la fijada en `install` (está en el .env).
222
+ // No se vuelve a preguntar: cambiarla aquí no surte efecto sobre un
223
+ // volumen de Postgres ya inicializado, así que evitamos esa trampa.
224
+ const dbPassword = readEnv(envPath).POSTGRES_PASSWORD || (0, helpers_1.generatePassword)(24);
152
225
  // 3. Construir configuración
153
- const protocol = useSSL ? 'https' : 'http';
154
- const webUrl = webPort === '80' || webPort === '443'
155
- ? `${protocol}://${host}`
156
- : `${protocol}://${host}:${webPort}`;
157
- const apiUrl = serverPort === '80' || serverPort === '443'
158
- ? `${protocol}://${host}`
159
- : `${protocol}://${host}:${serverPort}`;
226
+ // Con SSL el ÚNICO punto de entrada es Caddy en httpsPort: enruta
227
+ // "/" web:80 y "/api/*" server:3000. Por eso las URLs de cara al
228
+ // usuario (web, API, VITE_API_URL, CORS_ORIGIN) deben apuntar a Caddy,
229
+ // no a los puertos HTTP planos web/api (8080/3000).
230
+ let webUrl;
231
+ let apiUrl;
232
+ if (useSSL) {
233
+ const base = httpsPort === '443' ? `https://${host}` : `https://${host}:${httpsPort}`;
234
+ webUrl = base;
235
+ apiUrl = base;
236
+ }
237
+ else {
238
+ webUrl = webPort === '80' || webPort === '443'
239
+ ? `http://${host}`
240
+ : `http://${host}:${webPort}`;
241
+ apiUrl = serverPort === '80' || serverPort === '443'
242
+ ? `http://${host}`
243
+ : `http://${host}:${serverPort}`;
244
+ }
160
245
  logger_1.log.blank();
161
246
  logger_1.log.title(' Resumen de configuración');
162
247
  logger_1.log.info(`Web: ${chalk_1.default.cyan(webUrl)}`);
163
248
  logger_1.log.info(`API: ${chalk_1.default.cyan(apiUrl)}`);
164
- logger_1.log.info(`BD Password: ${chalk_1.default.dim(dbPassword === 'openfactu_pass' ? '(default)' : '****')}`);
249
+ logger_1.log.info(`BD Puerto: ${chalk_1.default.cyan(dbPort)} ${chalk_1.default.dim('(host)')}`);
250
+ logger_1.log.info(`BD Password: ${chalk_1.default.dim('(reutilizada de install)')}`);
165
251
  logger_1.log.info(`SSL: ${useSSL ? chalk_1.default.green('Si') : chalk_1.default.dim('No')}`);
166
252
  logger_1.log.blank();
167
253
  const { confirm } = await inquirer_1.default.prompt([
@@ -176,11 +262,11 @@ function registerDeployCommand(program) {
176
262
  const env = readEnv(envPath);
177
263
  env.SERVER_PORT = serverPort;
178
264
  env.WEB_PORT = webPort;
179
- env.DB_PORT = env.DB_PORT || '5432';
265
+ env.DB_PORT = dbPort;
180
266
  env.POSTGRES_USER = env.POSTGRES_USER || 'openfactu';
181
267
  env.POSTGRES_PASSWORD = dbPassword;
182
268
  env.POSTGRES_DB = env.POSTGRES_DB || 'openfactudb';
183
- env.DATABASE_URL = `postgresql://${env.POSTGRES_USER}:${dbPassword}@db:5432/${env.POSTGRES_DB}`;
269
+ env.DATABASE_URL = `postgresql://${env.POSTGRES_USER}:${encodeURIComponent(dbPassword)}@db:5432/${env.POSTGRES_DB}`;
184
270
  env.VITE_API_URL = apiUrl;
185
271
  env.HOST = host;
186
272
  env.CORS_ORIGIN = webUrl;
@@ -240,47 +326,164 @@ function registerDeployCommand(program) {
240
326
  restart: unless-stopped
241
327
  networks:
242
328
  - openfactu_net
329
+ `;
330
+ // Si SSL, añadir Caddy reverse proxy con Let's Encrypt (servicio dentro de
331
+ // services:). El bloque top-level networks: se añade SIEMPRE al final.
332
+ if (useSSL) {
333
+ composeContent += `
334
+ caddy:
335
+ image: caddy:2-alpine
336
+ container_name: openfactu-caddy
337
+ ports:
338
+ - "0.0.0.0:${httpPort}:80"
339
+ - "0.0.0.0:${httpsPort}:443"
340
+ volumes:
341
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
342
+ - caddy_data:/data
343
+ - caddy_config:/config
344
+ depends_on:
345
+ - web
346
+ - server
347
+ restart: unless-stopped
348
+ networks:
349
+ - openfactu_net
243
350
 
351
+ volumes:
352
+ caddy_data:
353
+ caddy_config:
354
+ `;
355
+ // Generar Caddyfile
356
+ const caddyfilePath = path_1.default.join(root, 'Caddyfile');
357
+ const isLAN = mode === 'lan';
358
+ const tlsDirective = isLAN ? 'tls internal' : '';
359
+ const httpRedirect = isLAN ? '' : `
360
+ http://${host} {
361
+ redir https://{host}{uri} permanent
362
+ }
363
+ `;
364
+ const caddyfileContent = `${host} {
365
+ ${tlsDirective}
366
+ encode gzip
367
+
368
+ handle /api/* {
369
+ reverse_proxy server:3000
370
+ }
371
+
372
+ handle {
373
+ reverse_proxy web:80
374
+ }
375
+
376
+ log {
377
+ output file /var/log/caddy/access.log
378
+ }
379
+ }
380
+ ${httpRedirect}`;
381
+ fs_1.default.writeFileSync(caddyfilePath, caddyfileContent);
382
+ composeContent += `
244
383
  networks:
245
384
  openfactu_net:
246
385
  driver: bridge
247
386
  `;
248
- // Si SSL, añadir nginx reverse proxy
249
- if (useSSL) {
387
+ fs_1.default.writeFileSync(prodComposePath, composeContent);
388
+ prodSpinner.succeed('docker-compose.prod.yml y Caddyfile generados');
389
+ logger_1.log.blank();
390
+ if (isLAN) {
391
+ logger_1.log.info('Caddy generará certificado auto-firmado para la red local');
392
+ logger_1.log.dim(' Los navegadores mostrarán advertencia de seguridad (es normal)');
393
+ }
394
+ else {
395
+ logger_1.log.info('Caddy obtendrá certificados Let\'s Encrypt automáticamente');
396
+ logger_1.log.dim(' El DNS debe apuntar a este servidor');
397
+ logger_1.log.dim(' El primer request tardará unos segundos mientras se obtiene el certificado');
398
+ }
399
+ if (httpPort !== '80' || httpsPort !== '443') {
400
+ logger_1.log.blank();
401
+ logger_1.log.info(`Puertos alternativos: HTTP=${httpPort}, HTTPS=${httpsPort}`);
402
+ logger_1.log.dim(' Asegúrate de abrir estos puertos en el firewall');
403
+ }
404
+ }
405
+ else {
250
406
  composeContent += `
251
- # Para SSL, configura un reverse proxy (nginx, traefik, caddy) delante.
252
- # Ejemplo con Caddy (descomentar):
253
- #
254
- # caddy:
255
- # image: caddy:2-alpine
256
- # ports:
257
- # - "0.0.0.0:80:80"
258
- # - "0.0.0.0:443:443"
259
- # volumes:
260
- # - ./Caddyfile:/etc/caddy/Caddyfile
261
- # - caddy_data:/data
262
- # depends_on:
263
- # - web
264
- # - server
265
- # networks:
266
- # - openfactu_net
267
- #
268
- # volumes:
269
- # caddy_data:
270
- #
271
- # Caddyfile:
272
- # ${host} {
273
- # handle /api/* {
274
- # reverse_proxy server:3000
275
- # }
276
- # handle {
277
- # reverse_proxy web:80
278
- # }
279
- # }
407
+ networks:
408
+ openfactu_net:
409
+ driver: bridge
280
410
  `;
411
+ fs_1.default.writeFileSync(prodComposePath, composeContent);
412
+ prodSpinner.succeed('docker-compose.prod.yml generado');
413
+ }
414
+ // Generar docker-compose.prod.monitoring.yml si se pidió monitoreo
415
+ if (opts.withMonitoring) {
416
+ const monSpinner = (0, ora_1.default)('Generando docker-compose.prod.monitoring.yml...').start();
417
+ const monitoringComposePath = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
418
+ const monitoringCompose = `services:
419
+ pgadmin:
420
+ image: dpage/pgadmin4:latest
421
+ environment:
422
+ PGADMIN_DEFAULT_EMAIL: \${PGADMIN_EMAIL:-admin@openfactu.local}
423
+ PGADMIN_DEFAULT_PASSWORD: \${PGADMIN_PASSWORD:-admin}
424
+ PGADMIN_CONFIG_SERVER_MODE: 'False'
425
+ ports:
426
+ - "0.0.0.0:\${PGADMIN_PORT:-5050}:80"
427
+ volumes:
428
+ - ./storage/pgadmin_data:/var/lib/pgadmin
429
+ depends_on:
430
+ - db
431
+ restart: unless-stopped
432
+ networks:
433
+ - openfactu_net
434
+
435
+ grafana:
436
+ image: grafana/grafana:latest
437
+ environment:
438
+ - GF_SECURITY_ADMIN_USER=\${GRAFANA_USER:-admin}
439
+ - GF_SECURITY_ADMIN_PASSWORD=\${GRAFANA_PASSWORD:-admin}
440
+ - GF_USERS_ALLOW_SIGN_UP=false
441
+ ports:
442
+ - "0.0.0.0:\${GRAFANA_PORT:-3001}:3000"
443
+ volumes:
444
+ - ./storage/grafana_data:/var/lib/grafana
445
+ depends_on:
446
+ - prometheus
447
+ restart: unless-stopped
448
+ networks:
449
+ - openfactu_net
450
+
451
+ prometheus:
452
+ image: prom/prometheus:latest
453
+ command:
454
+ - '--config.file=/etc/prometheus/prometheus.yml'
455
+ - '--storage.tsdb.path=/prometheus'
456
+ - '--storage.tsdb.retention.time=15d'
457
+ - '--web.enable-lifecycle'
458
+ ports:
459
+ - "0.0.0.0:\${PROMETHEUS_PORT:-9090}:9090"
460
+ volumes:
461
+ - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
462
+ - ./storage/prometheus_data:/prometheus
463
+ restart: unless-stopped
464
+ networks:
465
+ - openfactu_net
466
+
467
+ portainer:
468
+ image: portainer/portainer-ce:latest
469
+ command: -H unix:///var/run/docker.sock
470
+ ports:
471
+ - "0.0.0.0:\${PORTAINER_PORT:-9000}:9000"
472
+ volumes:
473
+ - /var/run/docker.sock:/var/run/docker.sock:ro
474
+ - ./storage/portainer_data:/data
475
+ restart: unless-stopped
476
+ networks:
477
+ - openfactu_net
478
+
479
+ networks:
480
+ openfactu_net:
481
+ name: openfactu_net
482
+ driver: bridge
483
+ `;
484
+ fs_1.default.writeFileSync(monitoringComposePath, monitoringCompose);
485
+ monSpinner.succeed('docker-compose.prod.monitoring.yml generado');
281
486
  }
282
- fs_1.default.writeFileSync(prodComposePath, composeContent);
283
- prodSpinner.succeed('docker-compose.prod.yml generado');
284
487
  // 6. Preguntar si levantar
285
488
  logger_1.log.blank();
286
489
  const { start } = await inquirer_1.default.prompt([
@@ -319,7 +522,9 @@ networks:
319
522
  else if (mode === 'public') {
320
523
  logger_1.log.info('Asegúrate de que los puertos estén abiertos en el firewall');
321
524
  if (useSSL) {
322
- logger_1.log.info('Configura el reverse proxy (Caddy/Nginx) para SSL');
525
+ logger_1.log.info('Caddy obtendrá certificado Let\'s Encrypt automáticamente');
526
+ logger_1.log.dim(' El DNS debe apuntar a este servidor para que funcione');
527
+ logger_1.log.dim(' Puertos requeridos: 80 (HTTP) y 443 (HTTPS)');
323
528
  }
324
529
  }
325
530
  logger_1.log.blank();
@@ -334,31 +539,223 @@ networks:
334
539
  process.exitCode = 1;
335
540
  }
336
541
  });
542
+ // ── openfactu deploy:ssl ──
543
+ program
544
+ .command('deploy:ssl')
545
+ .description('Activa HTTPS en un despliegue existente con Caddy')
546
+ .option('--domain <domain>', 'Dominio para el certificado')
547
+ .option('--lan', 'Usar certificado auto-firmado para red local')
548
+ .option('--port <port>', 'Puerto HTTPS (default: 443)', '443')
549
+ .action(async (opts) => {
550
+ console.log();
551
+ console.log(chalk_1.default.bold.white(' OpenFactu — Activar HTTPS'));
552
+ console.log(chalk_1.default.dim(' ────────────────────────────────────'));
553
+ console.log();
554
+ try {
555
+ const root = (0, paths_1.getProjectRoot)();
556
+ const envPath = path_1.default.join(root, '.env');
557
+ const env = readEnv(envPath);
558
+ const host = opts.domain || env.HOST || 'localhost';
559
+ const isLAN = opts.lan || false;
560
+ if (!isLAN && !opts.domain) {
561
+ const { domain } = await inquirer_1.default.prompt([
562
+ {
563
+ type: 'input',
564
+ name: 'domain',
565
+ message: 'Dominio para el certificado Let\'s Encrypt:',
566
+ default: host,
567
+ },
568
+ ]);
569
+ opts.domain = domain;
570
+ }
571
+ const finalHost = opts.domain || host;
572
+ let httpsPort = opts.port || '443';
573
+ let httpPort = '80';
574
+ // Verificar conflictos de puertos
575
+ const port80 = checkPortInUse(80);
576
+ const port443 = checkPortInUse(443);
577
+ if (port80.inUse || port443.inUse) {
578
+ logger_1.log.blank();
579
+ logger_1.log.warn('Puertos en conflicto detectados:');
580
+ if (port80.inUse)
581
+ logger_1.log.warn(` Puerto 80: ocupado por ${port80.process}`);
582
+ if (port443.inUse)
583
+ logger_1.log.warn(` Puerto 443: ocupado por ${port443.process}`);
584
+ logger_1.log.blank();
585
+ const { portAction } = await inquirer_1.default.prompt([
586
+ {
587
+ type: 'list',
588
+ name: 'portAction',
589
+ message: '¿Cómo resolver el conflicto?',
590
+ choices: [
591
+ { name: 'Usar puertos alternativos (8080/8443)', value: 'alternate' },
592
+ { name: 'Especificar puertos personalizados', value: 'custom' },
593
+ { name: 'Continuar con 80/443 (puede fallar)', value: 'continue' },
594
+ ],
595
+ },
596
+ ]);
597
+ if (portAction === 'alternate') {
598
+ httpPort = '8080';
599
+ httpsPort = '8443';
600
+ }
601
+ else if (portAction === 'custom') {
602
+ const { customHttp, customHttps } = await inquirer_1.default.prompt([
603
+ { type: 'input', name: 'customHttp', message: 'Puerto HTTP alternativo:', default: '8080' },
604
+ { type: 'input', name: 'customHttps', message: 'Puerto HTTPS alternativo:', default: '8443' },
605
+ ]);
606
+ httpPort = customHttp;
607
+ httpsPort = customHttps;
608
+ }
609
+ }
610
+ // Generar Caddyfile
611
+ const tlsDirective = isLAN ? 'tls internal' : '';
612
+ const httpRedirect = isLAN ? '' : `
613
+ http://${finalHost} {
614
+ redir https://${finalHost}{uri} permanent
615
+ }
616
+ `;
617
+ const caddyfileContent = `${finalHost} {
618
+ ${tlsDirective}
619
+ encode gzip
620
+
621
+ handle /api/* {
622
+ reverse_proxy server:3000
623
+ }
624
+
625
+ handle {
626
+ reverse_proxy web:80
627
+ }
628
+
629
+ log {
630
+ output file /var/log/caddy/access.log
631
+ }
632
+ }
633
+ ${httpRedirect}`;
634
+ const caddyfilePath = path_1.default.join(root, 'Caddyfile');
635
+ fs_1.default.writeFileSync(caddyfilePath, caddyfileContent);
636
+ // Actualizar docker-compose.prod.yml para añadir Caddy
637
+ const prodComposePath = path_1.default.join(root, 'docker-compose.prod.yml');
638
+ let composeContent = fs_1.default.existsSync(prodComposePath)
639
+ ? fs_1.default.readFileSync(prodComposePath, 'utf-8')
640
+ : '';
641
+ if (!composeContent.includes('caddy:')) {
642
+ const caddyBlock = `
643
+ caddy:
644
+ image: caddy:2-alpine
645
+ container_name: openfactu-caddy
646
+ ports:
647
+ - "0.0.0.0:${httpPort}:80"
648
+ - "0.0.0.0:${httpsPort}:443"
649
+ volumes:
650
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
651
+ - caddy_data:/data
652
+ - caddy_config:/config
653
+ depends_on:
654
+ - web
655
+ - server
656
+ restart: unless-stopped
657
+ networks:
658
+ - openfactu_net
659
+ `;
660
+ const volumesBlock = `
661
+ volumes:
662
+ caddy_data:
663
+ caddy_config:
664
+ `;
665
+ // Insertar caddy DENTRO de services (antes del bloque top-level
666
+ // "networks:"), no al final, para que no quede anidado bajo networks:.
667
+ if (/\nnetworks:/.test(composeContent)) {
668
+ composeContent = composeContent.replace(/\nnetworks:/, () => `${caddyBlock}${volumesBlock}\nnetworks:`);
669
+ }
670
+ else {
671
+ composeContent += caddyBlock + volumesBlock;
672
+ }
673
+ fs_1.default.writeFileSync(prodComposePath, composeContent);
674
+ }
675
+ // Actualizar .env
676
+ const protocol = 'https';
677
+ const webUrl = httpsPort === '443'
678
+ ? `${protocol}://${finalHost}`
679
+ : `${protocol}://${finalHost}:${httpsPort}`;
680
+ env.VITE_API_URL = webUrl;
681
+ env.CORS_ORIGIN = webUrl;
682
+ env.HOST = finalHost;
683
+ writeEnv(envPath, env);
684
+ logger_1.log.blank();
685
+ logger_1.log.success('HTTPS configurado');
686
+ logger_1.log.blank();
687
+ logger_1.log.info(`URL: ${chalk_1.default.cyan(webUrl)}`);
688
+ if (httpPort !== '80' || httpsPort !== '443') {
689
+ logger_1.log.info(`Puertos: HTTP=${httpPort}, HTTPS=${httpsPort}`);
690
+ }
691
+ if (isLAN) {
692
+ logger_1.log.dim(' Certificado auto-firmado (los navegadores mostrarán advertencia)');
693
+ }
694
+ else {
695
+ logger_1.log.dim(' Caddy obtendrá certificado Let\'s Encrypt en el primer request');
696
+ logger_1.log.dim(' El DNS debe apuntar a este servidor');
697
+ }
698
+ logger_1.log.blank();
699
+ const { restart } = await inquirer_1.default.prompt([
700
+ {
701
+ type: 'confirm',
702
+ name: 'restart',
703
+ message: '¿Reiniciar servicios para aplicar HTTPS?',
704
+ default: true,
705
+ },
706
+ ]);
707
+ if (restart) {
708
+ const spinner = (0, ora_1.default)('Reiniciando servicios...').start();
709
+ try {
710
+ (0, child_process_1.execSync)('docker compose -f docker-compose.prod.yml up -d', {
711
+ cwd: root,
712
+ stdio: 'pipe',
713
+ timeout: 120000,
714
+ });
715
+ spinner.succeed('Servicios reiniciados');
716
+ }
717
+ catch (err) {
718
+ spinner.fail('Error: ' + err.message);
719
+ }
720
+ }
721
+ }
722
+ catch (err) {
723
+ logger_1.log.error(err.message);
724
+ process.exitCode = 1;
725
+ }
726
+ });
337
727
  // ── openfactu deploy:status ──
338
728
  program
339
729
  .command('deploy:status')
340
730
  .description('Muestra el estado de los servicios Docker')
341
- .action(async () => {
731
+ .option('--with-monitoring', 'Incluir servicios de monitoreo')
732
+ .action(async (opts) => {
342
733
  try {
343
734
  const root = (0, paths_1.getProjectRoot)();
344
735
  const prodCompose = path_1.default.join(root, 'docker-compose.prod.yml');
345
736
  const composeFile = fs_1.default.existsSync(prodCompose) ? 'docker-compose.prod.yml' : 'docker-compose.yml';
737
+ const monitoringCompose = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
738
+ const useMonitoring = opts.withMonitoring && fs_1.default.existsSync(monitoringCompose);
346
739
  logger_1.log.info(`Usando: ${chalk_1.default.dim(composeFile)}`);
740
+ if (useMonitoring)
741
+ logger_1.log.info(`Monitoreo: ${chalk_1.default.dim('docker-compose.prod.monitoring.yml')}`);
347
742
  logger_1.log.blank();
348
- const output = (0, child_process_1.execSync)(`docker compose -f ${composeFile} ps`, {
743
+ const files = useMonitoring
744
+ ? `-f ${composeFile} -f docker-compose.prod.monitoring.yml`
745
+ : `-f ${composeFile}`;
746
+ const output = (0, child_process_1.execSync)(`docker compose ${files} ps`, {
349
747
  cwd: root,
350
748
  }).toString();
351
749
  console.log(output);
352
- // Mostrar URLs
750
+ // Mostrar URLs realmente configuradas en .env (correctas con o sin SSL/Caddy)
353
751
  const envPath = path_1.default.join(root, '.env');
354
752
  const env = readEnv(envPath);
355
753
  const host = env.HOST || 'localhost';
356
- const webPort = env.WEB_PORT || '8080';
357
- const serverPort = env.SERVER_PORT || '3000';
358
- const protocol = env.VITE_API_URL?.startsWith('https') ? 'https' : 'http';
754
+ const webUrl = env.CORS_ORIGIN || `http://${host}:${env.WEB_PORT || '8080'}`;
755
+ const apiUrl = env.VITE_API_URL || `http://${host}:${env.SERVER_PORT || '3000'}`;
359
756
  logger_1.log.blank();
360
- logger_1.log.info(`Web: ${chalk_1.default.cyan(`${protocol}://${host}:${webPort}`)}`);
361
- logger_1.log.info(`API: ${chalk_1.default.cyan(`${protocol}://${host}:${serverPort}`)}`);
757
+ logger_1.log.info(`Web: ${chalk_1.default.cyan(webUrl)}`);
758
+ logger_1.log.info(`API: ${chalk_1.default.cyan(apiUrl)}`);
362
759
  }
363
760
  catch (err) {
364
761
  logger_1.log.error('Docker no disponible o servicios no levantados');
@@ -371,18 +768,26 @@ networks:
371
768
  .description('Reconstruye y reinicia los contenedores Docker')
372
769
  .option('--service <name>', 'Reconstruir solo un servicio (web, server, db)')
373
770
  .option('--no-cache', 'Construir sin cache de Docker')
771
+ .option('--with-monitoring', 'Incluir servicios de monitoreo')
374
772
  .action(async (opts) => {
375
773
  try {
376
774
  const root = (0, paths_1.getProjectRoot)();
377
775
  const prodCompose = path_1.default.join(root, 'docker-compose.prod.yml');
378
776
  const composeFile = fs_1.default.existsSync(prodCompose) ? 'docker-compose.prod.yml' : 'docker-compose.yml';
777
+ const monitoringCompose = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
778
+ const useMonitoring = opts.withMonitoring && fs_1.default.existsSync(monitoringCompose);
379
779
  const service = opts.service || '';
380
780
  const noCache = opts.cache === false ? ' --no-cache' : '';
381
781
  logger_1.log.info(`Usando: ${chalk_1.default.dim(composeFile)}`);
782
+ if (useMonitoring)
783
+ logger_1.log.info(`Monitoreo: ${chalk_1.default.dim('docker-compose.prod.monitoring.yml')}`);
382
784
  logger_1.log.blank();
785
+ const files = useMonitoring
786
+ ? `-f ${composeFile} -f docker-compose.prod.monitoring.yml`
787
+ : `-f ${composeFile}`;
383
788
  const buildSpinner = (0, ora_1.default)(`Construyendo${service ? ' ' + service : ' todos los servicios'}...`).start();
384
789
  try {
385
- (0, child_process_1.execSync)(`docker compose -f ${composeFile} build${noCache} ${service}`, {
790
+ (0, child_process_1.execSync)(`docker compose ${files} build${noCache} ${service}`, {
386
791
  cwd: root,
387
792
  stdio: 'pipe',
388
793
  timeout: 600000,
@@ -404,7 +809,7 @@ networks:
404
809
  }
405
810
  const upSpinner = (0, ora_1.default)('Reiniciando servicios...').start();
406
811
  try {
407
- (0, child_process_1.execSync)(`docker compose -f ${composeFile} up -d ${service}`, {
812
+ (0, child_process_1.execSync)(`docker compose ${files} up -d ${service}`, {
408
813
  cwd: root,
409
814
  stdio: 'pipe',
410
815
  timeout: 60000,
@@ -437,14 +842,20 @@ networks:
437
842
  .description('Muestra los logs de los servicios Docker')
438
843
  .option('--service <name>', 'Logs de un servicio especifico (web, server, db)')
439
844
  .option('-n, --lines <number>', 'Numero de lineas', '50')
845
+ .option('--with-monitoring', 'Incluir servicios de monitoreo')
440
846
  .action(async (opts) => {
441
847
  try {
442
848
  const root = (0, paths_1.getProjectRoot)();
443
849
  const prodCompose = path_1.default.join(root, 'docker-compose.prod.yml');
444
850
  const composeFile = fs_1.default.existsSync(prodCompose) ? 'docker-compose.prod.yml' : 'docker-compose.yml';
851
+ const monitoringCompose = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
852
+ const useMonitoring = opts.withMonitoring && fs_1.default.existsSync(monitoringCompose);
445
853
  const service = opts.service || '';
446
854
  const lines = opts.lines || '50';
447
- (0, child_process_1.execSync)(`docker compose -f ${composeFile} logs --tail ${lines} ${service}`, {
855
+ const files = useMonitoring
856
+ ? `-f ${composeFile} -f docker-compose.prod.monitoring.yml`
857
+ : `-f ${composeFile}`;
858
+ (0, child_process_1.execSync)(`docker compose ${files} logs --tail ${lines} ${service}`, {
448
859
  cwd: root,
449
860
  stdio: 'inherit',
450
861
  });
@@ -457,13 +868,19 @@ networks:
457
868
  program
458
869
  .command('stop')
459
870
  .description('Para todos los servicios Docker')
460
- .action(async () => {
871
+ .option('--with-monitoring', 'Incluir servicios de monitoreo')
872
+ .action(async (opts) => {
461
873
  try {
462
874
  const root = (0, paths_1.getProjectRoot)();
463
875
  const prodCompose = path_1.default.join(root, 'docker-compose.prod.yml');
464
876
  const composeFile = fs_1.default.existsSync(prodCompose) ? 'docker-compose.prod.yml' : 'docker-compose.yml';
877
+ const monitoringCompose = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
878
+ const useMonitoring = opts.withMonitoring && fs_1.default.existsSync(monitoringCompose);
879
+ const files = useMonitoring
880
+ ? `-f ${composeFile} -f docker-compose.prod.monitoring.yml`
881
+ : `-f ${composeFile}`;
465
882
  const spinner = (0, ora_1.default)('Parando servicios...').start();
466
- (0, child_process_1.execSync)(`docker compose -f ${composeFile} down`, { cwd: root, stdio: 'pipe' });
883
+ (0, child_process_1.execSync)(`docker compose ${files} down`, { cwd: root, stdio: 'pipe' });
467
884
  spinner.succeed('Servicios parados');
468
885
  }
469
886
  catch (err) {
@@ -475,14 +892,20 @@ networks:
475
892
  .command('restart')
476
893
  .description('Reinicia los servicios Docker (sin rebuild)')
477
894
  .option('--service <name>', 'Reiniciar solo un servicio')
895
+ .option('--with-monitoring', 'Incluir servicios de monitoreo')
478
896
  .action(async (opts) => {
479
897
  try {
480
898
  const root = (0, paths_1.getProjectRoot)();
481
899
  const prodCompose = path_1.default.join(root, 'docker-compose.prod.yml');
482
900
  const composeFile = fs_1.default.existsSync(prodCompose) ? 'docker-compose.prod.yml' : 'docker-compose.yml';
901
+ const monitoringCompose = path_1.default.join(root, 'docker-compose.prod.monitoring.yml');
902
+ const useMonitoring = opts.withMonitoring && fs_1.default.existsSync(monitoringCompose);
903
+ const files = useMonitoring
904
+ ? `-f ${composeFile} -f docker-compose.prod.monitoring.yml`
905
+ : `-f ${composeFile}`;
483
906
  const service = opts.service || '';
484
907
  const spinner = (0, ora_1.default)('Reiniciando...').start();
485
- (0, child_process_1.execSync)(`docker compose -f ${composeFile} restart ${service}`, { cwd: root, stdio: 'pipe' });
908
+ (0, child_process_1.execSync)(`docker compose ${files} restart ${service}`, { cwd: root, stdio: 'pipe' });
486
909
  spinner.succeed('Servicios reiniciados');
487
910
  }
488
911
  catch (err) {