<# .SYNOPSIS Instala la API (que también sirve la web) como servicio de Windows usando NSSM. .EXAMPLE .\instalar-servicio.ps1 -Nssm C:\descargas\nssm\win64\nssm.exe .EXAMPLE .\instalar-servicio.ps1 -Nssm C:\descargas\nssm\win64\nssm.exe -Cuenta 'EMPRESA\svc-devolucion' -UsarCertificadosDelSistema .EXAMPLE # Muestra los comandos que ejecutaría, sin instalar nada ni exigir administrador .\instalar-servicio.ps1 -Nssm C:\descargas\nssm\win64\nssm.exe -SoloMostrar #> [CmdletBinding()] param( # Ruta de nssm.exe (la versión de 64 bits, carpeta win64 del .zip de https://nssm.cc/download) [Parameter(Mandatory)][string]$Nssm, # Cuenta con la que corre el servicio (DOMINIO\usuario). Si se omite: LocalSystem. [string]$Cuenta, [string]$NombreServicio = 'DevolucionProveedores', # Agrega NODE_OPTIONS=--use-system-ca: necesario si Fortinet u otro firewall inspecciona el tráfico # HTTPS de salida (por ejemplo hacia el web service de BES) con un certificado propio. [switch]$UsarCertificadosDelSistema, # Solo imprime los comandos de NSSM que se ejecutarían. [switch]$SoloMostrar ) $ErrorActionPreference = 'Stop' $raiz = $PSScriptRoot $apiDir = Join-Path $raiz 'app\apps\api' $logsDir = Join-Path $raiz 'logs' function Fallar($mensaje) { Write-Host "ERROR: $mensaje" -ForegroundColor Red; exit 1 } if (-not $SoloMostrar) { $esAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $esAdmin) { Fallar 'Ejecute este script en una consola de PowerShell como administrador.' } } # --- Verificaciones previas ------------------------------------------------------------------- if (-not (Test-Path $Nssm)) { Fallar "No se encontró nssm.exe en '$Nssm'." } $node = Get-Command node -ErrorAction SilentlyContinue if (-not $node) { Fallar 'Node.js no está instalado (o no está en el PATH). Instale Node 22 LTS.' } $versionNode = [version](& $node.Source --version).TrimStart('v') if ($versionNode.Major -lt 22) { Fallar "Se necesita Node 22 o superior; hay $versionNode." } if (-not (Test-Path (Join-Path $apiDir 'dist\main.js'))) { Fallar "Falta $apiDir\dist\main.js (¿se copió la carpeta app completa?)." } if (-not (Test-Path (Join-Path $raiz 'app\node_modules'))) { Fallar 'Faltan las dependencias (app\node_modules).' } if (-not (Test-Path (Join-Path $raiz 'app\apps\web\dist\web\browser\index.html'))) { Fallar 'Falta la web compilada (app\apps\web\dist\web\browser).' } if (-not (Test-Path (Join-Path $apiDir '.env'))) { Fallar "Falta $apiDir\.env. Copie .env.example a .env y complételo (ver DEPLOY.md)." } if (-not $SoloMostrar -and (Get-Service -Name $NombreServicio -ErrorAction SilentlyContinue)) { Fallar "El servicio '$NombreServicio' ya existe. Ejecute primero .\desinstalar-servicio.ps1." } # --- Configuración del servicio (todo lo que se le pide a NSSM, en un solo lugar) ----------------- $variablesDeEntorno = @('NODE_ENV=production', 'NO_COLOR=1') # NO_COLOR: sin códigos de color en los logs if ($UsarCertificadosDelSistema) { $variablesDeEntorno += 'NODE_OPTIONS=--use-system-ca' } $ajustes = [System.Collections.Generic.List[string[]]]::new() function Agregar([string[]]$ajuste) { $ajustes.Add($ajuste) } Agregar 'DisplayName', 'Devolucion a Proveedores' Agregar 'Description', 'API y web de Devolucion a Proveedores (SAP Business One)' Agregar 'AppDirectory', $apiDir # de ahí se lee el archivo .env Agregar (@('AppEnvironmentExtra') + $variablesDeEntorno) Agregar 'Start', 'SERVICE_AUTO_START' Agregar 'AppStdout', (Join-Path $logsDir 'devolucion-api.out.log') # lo que la API escribe por consola Agregar 'AppStderr', (Join-Path $logsDir 'devolucion-api.err.log') Agregar 'AppRotateFiles', '1' # rota los logs al llegar a 10 MB, Agregar 'AppRotateOnline', '1' # incluso con el servicio en marcha Agregar 'AppRotateBytes', '10485760' Agregar 'AppExit', 'Default', 'Restart' # si Node se cae, lo reinicia Agregar 'AppRestartDelay', '10000' # esperando 10 s $instalar = @('install', $NombreServicio, $node.Source, 'dist\main.js') function Texto([string[]]$partes) { ($partes | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } }) -join ' ' } if ($SoloMostrar) { Write-Host "Comandos que se ejecutarían (nssm = $Nssm):`n" Write-Host "nssm $(Texto $instalar)" foreach ($a in $ajustes) { Write-Host "nssm $(Texto (@('set', $NombreServicio) + $a))" } Write-Host "nssm start $NombreServicio" exit 0 } # --- Instalación -------------------------------------------------------------------------------- New-Item -ItemType Directory -Force -Path (Join-Path $raiz 'servicio'), $logsDir | Out-Null # El servicio queda apuntando a este nssm.exe, así que se guarda una copia en una carpeta estable. $nssmExe = Join-Path $raiz 'servicio\nssm.exe' Copy-Item -Path $Nssm -Destination $nssmExe -Force function Ejecutar-Nssm([string[]]$argumentos) { & $nssmExe @argumentos | Out-Null if ($LASTEXITCODE -ne 0) { Fallar "nssm $(Texto $argumentos) falló (código $LASTEXITCODE)." } } Write-Host "Instalando el servicio '$NombreServicio'..." Ejecutar-Nssm $instalar try { foreach ($a in $ajustes) { Ejecutar-Nssm (@('set', $NombreServicio) + $a) } } catch { & $nssmExe remove $NombreServicio confirm | Out-Null # no dejar un servicio a medio configurar throw } if ($Cuenta) { # La contraseña se pide aquí y se pasa a Windows; no queda escrita en ningún archivo. $credencial = Get-Credential -UserName $Cuenta -Message 'Contraseña de la cuenta del servicio' $clave = $credencial.GetNetworkCredential().Password & sc.exe config $NombreServicio obj= $credencial.UserName password= $clave | Out-Null if ($LASTEXITCODE -ne 0) { Fallar "No se pudo asignar la cuenta '$Cuenta' al servicio." } Write-Host "Cuenta del servicio: $($credencial.UserName). Recuerde darle 'Iniciar sesión como servicio' y permiso de lectura sobre $raiz." } Write-Host 'Iniciando...' Ejecutar-Nssm @('start', $NombreServicio) Start-Sleep -Seconds 3 & $nssmExe status $NombreServicio Write-Host "`nLogs: $logsDir" -ForegroundColor Green Write-Host 'Compruebe: Invoke-WebRequest /api/health' -ForegroundColor Green