Despliegue en Windows con NSSM en lugar de WinSW

El servicio de Windows pasa a administrarse con NSSM (decisión del usuario: WinSW no se
consideró lo bastante estable).

- instalar-servicio.ps1: recibe -Nssm (ruta de nssm.exe) y configura el servicio con
  `nssm install` / `nssm set` (arranque automático, reinicio a los 10 s, logs a logs\ rotados
  a los 10 MB). Nueva opción -SoloMostrar que imprime los comandos sin ejecutarlos ni exigir
  administrador. Si un ajuste falla, quita el servicio a medio configurar.
- desinstalar-servicio.ps1: usa `nssm stop` y `nssm remove`.
- Se elimina la plantilla XML de WinSW.
- DEPLOY.md: pasos con NSSM, sección para administrar el servicio, aviso de usar la
  compilación 2.24-101 en Windows 10/11 y Server 2019/2022 (la 2.24 estable puede no arrancar),
  y limpieza de logs rotados (NSSM no limita cuántos conserva). Ya no se requiere .NET Framework.
- CLAUDE.md y README de la API actualizados.

Sin probar aún en Windows: solo se comprobó la sintaxis y los comandos generados (-SoloMostrar).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5.5 committed 2026-10-13 08:55:29 -05:00
1 parent 287b187c14
commit 699dd983d0
6 files changed
+147 -99

No files matched your search

+75 -33
View File
@@ -1,76 +1,118 @@
<#
.SYNOPSIS
Instala la API (que también sirve la web) como servicio de Windows usando WinSW.
Instala la API (que también sirve la web) como servicio de Windows usando NSSM.
.EXAMPLE
.\instalar-servicio.ps1 -WinSW C:\descargas\WinSW-x64.exe
.\instalar-servicio.ps1 -Nssm C:\descargas\nssm\win64\nssm.exe
.EXAMPLE
.\instalar-servicio.ps1 -WinSW C:\descargas\WinSW-x64.exe -Cuenta 'EMPRESA\svc-devolucion' -UsarCertificadosDelSistema
.\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
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
# Ruta del ejecutable de WinSW (WinSW-x64.exe, de https://github.com/winsw/winsw/releases)
[Parameter(Mandatory)][string]$WinSW,
# 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
[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 $WinSW)) { Fallar "No se encontró WinSW en '$WinSW'." }
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 $apiDir 'node_modules')) -and -not (Test-Path (Join-Path $raiz 'app\node_modules'))) { Fallar 'Faltan las dependencias (app\node_modules).' }
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 (Get-Service -Name $NombreServicio -ErrorAction SilentlyContinue) {
if (-not $SoloMostrar -and (Get-Service -Name $NombreServicio -ErrorAction SilentlyContinue)) {
Fallar "El servicio '$NombreServicio' ya existe. Ejecute primero .\desinstalar-servicio.ps1."
}
# --- Archivos del servicio ----------------------------------------------------------------------
$servicioDir = Join-Path $raiz 'servicio'
New-Item -ItemType Directory -Force -Path $servicioDir, (Join-Path $raiz 'logs') | Out-Null
# --- 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' }
$exe = Join-Path $servicioDir 'devolucion-api.exe'
$xml = Join-Path $servicioDir 'devolucion-api.xml'
Copy-Item -Path $WinSW -Destination $exe -Force
$ajustes = [System.Collections.Generic.List[string[]]]::new()
function Agregar([string[]]$ajuste) { $ajustes.Add($ajuste) }
$nodeOptions = if ($UsarCertificadosDelSistema) { ' <env name="NODE_OPTIONS" value="--use-system-ca"/>' } else { '' }
# .Replace() sustituye texto literal (las rutas pueden tener espacios, puntos o signos que -replace trataría como patrón)
$contenido = (Get-Content (Join-Path $raiz 'devolucion-api.xml.plantilla') -Raw -Encoding UTF8).
Replace('__ID__', $NombreServicio).
Replace('__NODE__', $node.Source).
Replace('__RAIZ__', $raiz).
Replace('__NODE_OPTIONS__', $nodeOptions)
if ($contenido -match '__[A-Z_]+__') { Fallar "La plantilla tiene un marcador sin reemplazar: $($Matches[0])" }
# UTF-8 sin BOM
[System.IO.File]::WriteAllText($xml, $contenido, (New-Object System.Text.UTF8Encoding($false)))
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'..."
& $exe install
if ($LASTEXITCODE -ne 0) { Fallar 'WinSW no pudo instalar el servicio.' }
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"
$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." }
@@ -78,9 +120,9 @@ if ($Cuenta) {
}
Write-Host 'Iniciando...'
& $exe start
Ejecutar-Nssm @('start', $NombreServicio)
Start-Sleep -Seconds 3
& $exe status
& $nssmExe status $NombreServicio
Write-Host "`nLogs: $(Join-Path $raiz 'logs')" -ForegroundColor Green
Write-Host "`nLogs: $logsDir" -ForegroundColor Green
Write-Host 'Compruebe: Invoke-WebRequest <su-url>/api/health' -ForegroundColor Green