Functions/Publish-NodeApi.ps1

<#
.SYNOPSIS
Despliega una API Node.js/TypeScript a un servidor Linux remoto vía SSH.
 
.DESCRIPTION
El cmdlet `Publish-NodeApi` gestiona el ciclo completo de despliegue de APIs TypeScript:
- Lee `name` y `version` de `package.json` (single source of truth).
- Lee la configuración de despliegue de `deploy.yaml`.
- Ejecuta build local (npm ci + tsc) y empaqueta los artefactos compilados.
- Sube dist/ + node_modules/ + package.json al servidor (no requiere internet en el servidor).
- Usa releases versionados con symlink `current` para rollback fácil.
- Soporta systemd (default) y PM2 como process managers.
 
Se debe ejecutar desde la raíz del proyecto donde existen:
  - package.json (name, version)
  - tsconfig.json (proyecto TypeScript)
  - deploy.yaml (servidor, runtime, health — generar con -Init)
  - .env.production (variables de entorno — se copia al servidor como .env)
 
.PARAMETER Init
Genera los archivos de configuración (deploy.yaml y .env.production) en el directorio actual.
Requiere que existan package.json y tsconfig.json.
 
.PARAMETER Publish
Ejecuta el despliegue completo al servidor remoto.
Lee deploy.yaml para el servidor destino y la configuración de runtime.
Siempre sube .env.production como .env dentro del release.
 
.PARAMETER DeployReport
Muestra las acciones que realizará -Publish sin ejecutarlas.
Consulta el servidor para mostrar: versión actual, si la release existe, estado del servicio.
 
.EXAMPLE
Publish-NodeApi -Init
 
Genera deploy.yaml y .env.production en el directorio actual del proyecto TypeScript.
 
.EXAMPLE
Publish-NodeApi -DeployReport
 
Muestra un reporte de lo que hará -Publish sin realizar cambios.
 
.EXAMPLE
Publish-NodeApi -Publish
 
Empaqueta, sube y despliega la API al servidor configurado en deploy.yaml.
 
.NOTES
Versión: 2.0.0
Autor: @ccisnedev
Requiere:
  - Configuración del host en ~/.ssh/config (Host, HostName, User, Port, IdentityFile)
  - Node.js y npm instalados localmente para el build
  - Node.js en el servidor remoto (solo runtime, no necesita internet)
  - PM2 o systemd según la configuración de deploy.yaml
  - Módulo powershell-yaml para parseo de deploy.yaml
#>

function Publish-NodeApi {

    [CmdletBinding(DefaultParameterSetName = 'Publish')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Init',
            HelpMessage = "Genera archivos de configuración (deploy.yaml y .env.production)")]
        [switch]$Init,

        [Parameter(Mandatory, ParameterSetName = 'Publish',
            HelpMessage = "Ejecuta el despliegue completo al servidor remoto")]
        [switch]$Publish,

        [Parameter(Mandatory, ParameterSetName = 'DeployReport',
            HelpMessage = "Muestra las acciones que realizará -Publish sin ejecutarlas")]
        [switch]$DeployReport
    )

    begin {
        $ErrorActionPreference = 'Stop'
    }

    process {
        # Banner
        Write-Host ""
        Write-Host "╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan
        Write-Host "║ Publish-NodeApi — macss-devops ║" -ForegroundColor Cyan
        Write-Host "╚══════════════════════════════════════════════════╝" -ForegroundColor Cyan
        Write-Host ""

        switch ($PSCmdlet.ParameterSetName) {

            # ═══════════════════════════════════════════════════
            # INIT — Generar deploy.yaml y .env.production
            # ═══════════════════════════════════════════════════
            'Init' {
                $cwd = (Get-Location).Path

                # Validar package.json
                $packageJsonPath = Join-Path $cwd "package.json"
                if (-not (Test-Path $packageJsonPath)) {
                    throw "No se encontró package.json en $cwd. Ejecute este cmdlet dentro de un proyecto Node.js."
                }

                # Validar tsconfig.json (solo TypeScript)
                $tsconfigPath = Join-Path $cwd "tsconfig.json"
                if (-not (Test-Path $tsconfigPath)) {
                    throw "No se encontró tsconfig.json en $cwd. Este cmdlet solo soporta proyectos TypeScript."
                }

                # Validar que deploy.yaml no exista
                $deployYamlPath = Join-Path $cwd "deploy.yaml"
                if (Test-Path $deployYamlPath) {
                    throw "Ya existe deploy.yaml en $cwd. Elimínelo primero si desea regenerar la configuración."
                }

                # Leer package.json para mostrar información
                $pkg = Get-Content $packageJsonPath -Raw | ConvertFrom-Json
                $appName = $pkg.name
                $appVersion = $pkg.version

                Write-Host " Proyecto: $appName" -ForegroundColor Cyan
                Write-Host " Versión: $appVersion" -ForegroundColor Cyan
                Write-Host ""

                # Copiar template deploy.yaml
                $templatePath = Join-Path $PSScriptRoot "..\Resources\Publish-NodeApi\templates\deploy.yaml"
                if (-not (Test-Path $templatePath)) {
                    throw "Template no encontrado: $templatePath"
                }
                Copy-Item -Path $templatePath -Destination $deployYamlPath
                Write-Host " Creado: deploy.yaml" -ForegroundColor Green

                # Crear .env.production si no existe
                $envProdPath = Join-Path $cwd ".env.production"
                if (-not (Test-Path $envProdPath)) {
                    $envContent = @"
# .env.production — Variables de entorno para producción
# Este archivo se copia al servidor como .env en cada despliegue.
# NO versionar este archivo (está en .gitignore).
 
PORT=8080
NODE_ENV=production
"@

                    Set-Content -Path $envProdPath -Value $envContent -Encoding UTF8
                    Write-Host " Creado: .env.production" -ForegroundColor Green
                } else {
                    Write-Host " Existe: .env.production (no se modificó)" -ForegroundColor Yellow
                }

                # Agregar .env.production a .gitignore
                $gitignorePath = Join-Path $cwd ".gitignore"
                if (Test-Path $gitignorePath) {
                    $gitignoreContent = Get-Content $gitignorePath -Raw
                    if ($gitignoreContent -notmatch '\.env\.production') {
                        Add-Content -Path $gitignorePath -Value "`n.env.production"
                        Write-Host " Actualizado: .gitignore (+.env.production)" -ForegroundColor Green
                    }
                } else {
                    Set-Content -Path $gitignorePath -Value ".env.production`n" -Encoding UTF8
                    Write-Host " Creado: .gitignore" -ForegroundColor Green
                }

                # Instrucciones
                Write-Host ""
                Write-Host " Configuración creada. Próximos pasos:" -ForegroundColor Green
                Write-Host " 1. Edite deploy.yaml → cambie 'server' por su alias SSH" -ForegroundColor DarkGray
                Write-Host " 2. Edite .env.production → configure variables de entorno" -ForegroundColor DarkGray
                Write-Host " 3. Ejecute: Publish-NodeApi -Publish" -ForegroundColor DarkGray
                Write-Host ""
            }

            # ═══════════════════════════════════════════════════
            # PUBLISH — Despliegue completo
            # ═══════════════════════════════════════════════════
            'Publish' {
                $cwd = (Get-Location).Path
                Ensure-YamlModule

                # ─── 0. Validaciones ─────────────────────────
                $deployYamlPath = Join-Path $cwd "deploy.yaml"
                $packageJsonPath = Join-Path $cwd "package.json"
                $tsconfigPath = Join-Path $cwd "tsconfig.json"
                $envProdPath = Join-Path $cwd ".env.production"

                if (-not (Test-Path $deployYamlPath)) {
                    throw "No se encontró deploy.yaml. Ejecute 'Publish-NodeApi -Init' primero."
                }
                if (-not (Test-Path $packageJsonPath)) {
                    throw "No se encontró package.json en $cwd."
                }
                if (-not (Test-Path $tsconfigPath)) {
                    throw "No se encontró tsconfig.json en $cwd."
                }
                if (-not (Test-Path $envProdPath)) {
                    throw "No se encontró .env.production. Cree el archivo con las variables de entorno de producción."
                }

                # ─── 1. Cargar helpers ───────────────────────
                . "$PSScriptRoot/../Private/PublishHelpers.ps1"
                . "$PSScriptRoot/../Private/Read-SSHConfig.ps1"

                # ─── 2. Leer configuración ───────────────────
                # deploy.yaml (server, runtime, health)
                $deployConfig = (Get-Content $deployYamlPath -Raw) | ConvertFrom-Yaml

                # package.json (name, version)
                $pkg = Get-Content $packageJsonPath -Raw | ConvertFrom-Json
                $appName = $pkg.name
                $appVersion = ($pkg.version -split '\+')[0]  # sin build metadata
                $release = "v$appVersion"

                # .env.production (PORT)
                $envConfig = Read-DotEnv -Path $envProdPath -DefaultPort 8080
                $port = $envConfig.Port

                # Extraer config de deploy.yaml con defaults
                $server = $deployConfig.server
                $processManager = if ($deployConfig.runtime -and $deployConfig.runtime.processManager) { 
                    $deployConfig.runtime.processManager 
                } else { 'systemd' }
                $nodeVersion = if ($deployConfig.runtime -and $deployConfig.runtime.nodeVersion) { 
                    $deployConfig.runtime.nodeVersion 
                } else { '>=18' }
                $healthRetries = if ($deployConfig.health -and $deployConfig.health.retries) { 
                    $deployConfig.health.retries 
                } else { 6 }
                $healthInterval = if ($deployConfig.health -and $deployConfig.health.interval) { 
                    $deployConfig.health.interval 
                } else { 3 }

                # ─── 3. Validaciones de config ───────────────
                if (-not $server) {
                    throw "No se encontró 'server:' en deploy.yaml."
                }
                if ($server -eq 'your-ssh-alias') {
                    throw "deploy.yaml contiene el valor de ejemplo 'your-ssh-alias'. Cambie 'server' por el alias SSH real de su servidor."
                }
                if ($processManager -notin @('systemd', 'pm2')) {
                    throw "Process manager '$processManager' no soportado. Use 'systemd' o 'pm2'."
                }

                Write-Host " Proyecto: $appName" -ForegroundColor Cyan
                Write-Host " Versión: $release" -ForegroundColor Cyan
                Write-Host " Servidor: $server" -ForegroundColor Cyan
                Write-Host " Proceso: $processManager" -ForegroundColor Cyan
                Write-Host " Puerto: $port" -ForegroundColor Cyan
                Write-Host ""

                # ─── 4. SSH Config ───────────────────────────
                $sshConfig = Read-SSHConfig -HostAlias $server
                $user = $sshConfig.User
                $ip = $sshConfig.HostName
                $sshPort = $sshConfig.Port
                $privateKeyPath = $sshConfig.IdentityFile

                # ─── Constantes remotas ──────────────────────
                $remoteRoot = "/opt/app"
                $releaseDir = "$remoteRoot/$appName/releases/$release"
                $currentLink = "$remoteRoot/$appName/current"
                $entryPath = "$currentLink/dist/main.js"
                $workingDir = "$currentLink"
                $envFile = "$currentLink/.env"
                $tarballName = "${appName}-${release}.tar.gz"
                $remoteTarball = "/tmp/$tarballName"
                $remoteEnvFile = "/tmp/${appName}.env.production"

                # ─── 5. Build local ──────────────────────────
                Write-Host " Compilando localmente..." -ForegroundColor Cyan

                # 5a. Instalar TODAS las dependencias (incluye devDeps para tsc)
                Write-Host " npm ci..." -ForegroundColor DarkGray
                $npmCiResult = & npm ci 2>&1
                if ($LASTEXITCODE -ne 0) {
                    $npmCiResult | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
                    throw "npm ci falló con código $LASTEXITCODE"
                }
                Write-Host " Dependencias instaladas" -ForegroundColor Green

                # 5b. Build TypeScript
                Write-Host " npm run build..." -ForegroundColor DarkGray
                $buildResult = & npm run build 2>&1
                if ($LASTEXITCODE -ne 0) {
                    $buildResult | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
                    throw "npm run build falló con código $LASTEXITCODE"
                }

                # Verificar que dist/main.js se generó
                $distMain = Join-Path $cwd "dist\main.js"
                if (-not (Test-Path $distMain)) {
                    throw "Build completó pero dist/main.js no existe. Verifique tsconfig.json."
                }
                Write-Host " Build completado (dist/main.js OK)" -ForegroundColor Green

                # 5c. Reinstalar solo dependencias de producción (para tarball liviano)
                Write-Host " npm ci --omit=dev (producción)..." -ForegroundColor DarkGray
                $npmProdResult = & npm ci --omit=dev 2>&1
                if ($LASTEXITCODE -ne 0) {
                    $npmProdResult | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
                    throw "npm ci --omit=dev falló con código $LASTEXITCODE"
                }
                Write-Host " node_modules optimizado para producción" -ForegroundColor Green

                # ─── 6. Empaquetar artefactos ────────────────
                Write-Host " Empaquetando artefactos..." -ForegroundColor Cyan

                $localTarball = Join-Path $env:TEMP $tarballName

                # Empaquetar solo: dist/ + node_modules/ + package.json
                # (los artefactos ya compilados, listos para ejecutar con node)
                $tarCmd = "tar.exe -czf `"$localTarball`" -C `"$cwd`" dist node_modules package.json"
                $tarResult = Invoke-Expression $tarCmd 2>&1

                if (-not (Test-Path $localTarball)) {
                    throw "Error al crear tarball: $tarResult"
                }

                $tarSize = [math]::Round((Get-Item $localTarball).Length / 1MB, 1)
                Write-Host " Tarball: $tarballName ($($tarSize) MB)" -ForegroundColor Green

                try {
                    # ─── 7. SCP: subir tarball + .env ────────
                    Write-Host " Subiendo archivos a $ip..." -ForegroundColor Cyan

                    # Subir tarball
                    $scpArgs = @('-i', $privateKeyPath, '-P', $sshPort, $localTarball, "$($user)@$($ip):$remoteTarball")
                    & scp @scpArgs 2>&1 | Out-Null
                    if ($LASTEXITCODE -ne 0) { throw "Error al subir tarball (scp exit: $LASTEXITCODE)" }
                    Write-Host " Tarball subido" -ForegroundColor Green

                    # Subir .env.production (normalizado a LF para compatibilidad con bash en Linux)
                    $envContent = Get-Content $envProdPath -Raw
                    $tmpEnvPath = New-UnixTempFile -Content $envContent -Prefix "psdevops_env_"
                    $scpEnvArgs = @('-i', $privateKeyPath, '-P', $sshPort, $tmpEnvPath, "$($user)@$($ip):$remoteEnvFile")
                    & scp @scpEnvArgs 2>&1 | Out-Null
                    if ($LASTEXITCODE -ne 0) { throw "Error al subir .env.production (scp exit: $LASTEXITCODE)" }
                    Write-Host " .env.production subido (LF)" -ForegroundColor Green

                    # ─── 8. Instalar release ─────────────────
                    Write-Host " Instalando release $release..." -ForegroundColor Cyan

                    $installScript = Get-BashScript -ScriptName "Install-NodeApi.sh" -Placeholders @{
                        '__NAME__'          = $appName
                        '__VERSION__'       = $appVersion
                        '__REMOTE_ROOT__'   = $remoteRoot
                        '__NODE_VERSION__'  = $nodeVersion
                        '__USER__'          = $user
                    }

                    $exitCode = Invoke-RemoteScript -ScriptContent $installScript `
                                                    -User $user -IP $ip -Port $sshPort `
                                                    -KeyPath $privateKeyPath `
                                                    -ScriptPrefix "psdevops_install_nodeapi_"

                    if ($exitCode -ne 0) {
                        throw "Instalación falló con código $exitCode. Revise la salida anterior."
                    }

                    # ─── 9. Gestionar proceso ────────────────
                    Write-Host " Configurando $processManager..." -ForegroundColor Cyan

                    $manageScript = Get-BashScript -ScriptName "Manage-NodeProcess.sh" -Placeholders @{
                        '__NAME__'             = $appName
                        '__PROCESS_MANAGER__'  = $processManager
                        '__ENTRY_PATH__'       = $entryPath
                        '__WORKING_DIR__'      = $workingDir
                        '__ENV_FILE__'         = $envFile
                        '__PORT__'             = $port.ToString()
                        '__USER__'             = $user
                    }

                    $exitCode = Invoke-RemoteScript -ScriptContent $manageScript `
                                                    -User $user -IP $ip -Port $sshPort `
                                                    -KeyPath $privateKeyPath `
                                                    -ScriptPrefix "psdevops_manage_nodeapi_"

                    if ($exitCode -ne 0) {
                        throw "Configuración de $processManager falló con código $exitCode"
                    }

                    # ─── 10. Healthcheck ─────────────────────
                    $healthUrl = "http://127.0.0.1:$port/health"
                    Write-Host " Verificando: $healthUrl" -ForegroundColor Cyan

                    $healthScript = Get-BashScript -ScriptName "Healthcheck.sh" -Placeholders @{
                        '__HEALTHURL__' = $healthUrl
                    }

                    $exitCode = Invoke-RemoteScript -ScriptContent $healthScript `
                                                    -User $user -IP $ip -Port $sshPort `
                                                    -KeyPath $privateKeyPath `
                                                    -ScriptPrefix "psdevops_health_nodeapi_"

                    if ($exitCode -ne 0) {
                        $logCmd = if ($processManager -eq 'pm2') { 
                            "pm2 logs $appName --lines 50" 
                        } else { 
                            "journalctl -u $appName --no-pager -n 50" 
                        }
                        throw "Healthcheck falló en $healthUrl. Revise logs con: ssh $user@$ip '$logCmd'"
                    }

                    # ─── Éxito ───────────────────────────────
                    Write-Host ""
                    Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green
                    Write-Host " Deploy completado: $appName $release" -ForegroundColor Green
                    Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green
                    Write-Host " Servidor: $ip" -ForegroundColor White
                    Write-Host " Release: $releaseDir" -ForegroundColor White
                    Write-Host " Proceso: $processManager" -ForegroundColor White
                    Write-Host " Health: $healthUrl" -ForegroundColor White
                    Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green
                    Write-Host ""

                } finally {
                    # Limpiar archivos temporales locales
                    Remove-Item -LiteralPath $localTarball -ErrorAction SilentlyContinue
                    if ($tmpEnvPath) { Remove-Item -LiteralPath $tmpEnvPath -ErrorAction SilentlyContinue }
                }
            }

            # ═══════════════════════════════════════════════════
            # DEPLOY REPORT — Reporte pre-deploy (dry-run)
            # ═══════════════════════════════════════════════════
            'DeployReport' {
                $cwd = (Get-Location).Path
                Ensure-YamlModule

                # ─── 0. Validaciones ─────────────────────────
                $packageJsonPath = Join-Path $cwd "package.json"
                $deployYamlPath = Join-Path $cwd "deploy.yaml"
                $envProdPath = Join-Path $cwd ".env.production"

                if (-not (Test-Path $packageJsonPath)) {
                    throw "No se encontró package.json en $cwd."
                }
                if (-not (Test-Path $deployYamlPath)) {
                    throw "No se encontró deploy.yaml. Ejecute 'Publish-NodeApi -Init' primero."
                }
                if (-not (Test-Path $envProdPath)) {
                    throw "No se encontró .env.production. Ejecute 'Publish-NodeApi -Init' primero."
                }

                # ─── 1. Cargar helpers ───────────────────────
                . "$PSScriptRoot/../Private/PublishHelpers.ps1"
                . "$PSScriptRoot/../Private/Read-SSHConfig.ps1"

                # ─── 2. Leer configuración ───────────────────
                $packageJson = Get-Content $packageJsonPath -Raw | ConvertFrom-Json
                $appName = $packageJson.name
                $appVersion = $packageJson.version
                $release = "v$appVersion"

                $deployConfig = Get-Content $deployYamlPath -Raw | ConvertFrom-Yaml
                $server = $deployConfig.server
                $processManager = if ($deployConfig.runtime -and $deployConfig.runtime.processManager) {
                    $deployConfig.runtime.processManager
                } else { 'systemd' }

                $envConfig = Read-DotEnv -Path $envProdPath -DefaultPort 8080
                $port = $envConfig.Port

                # ─── 3. Validaciones de config ───────────────
                if (-not $server) {
                    throw "No se encontró 'server:' en deploy.yaml."
                }
                if ($server -eq 'your-ssh-alias') {
                    throw "deploy.yaml contiene el valor de ejemplo 'your-ssh-alias'. Cambie 'server' por el alias SSH real de su servidor."
                }

                # ─── 4. SSH Config ───────────────────────────
                $sshConfig = Read-SSHConfig -HostAlias $server
                $user = $sshConfig.User
                $ip = $sshConfig.HostName
                $sshPort = $sshConfig.Port
                $privateKeyPath = $sshConfig.IdentityFile

                $remoteRoot = "/opt/app"

                Write-Host " Modo: SOLO REPORTE (no se realizarán cambios)" -ForegroundColor Yellow
                Write-Host ""
                Write-Host " ─── Configuración local ───" -ForegroundColor Cyan
                Write-Host " Proyecto: $appName" -ForegroundColor White
                Write-Host " Versión: $release" -ForegroundColor White
                Write-Host " Servidor: $server ($ip)" -ForegroundColor White
                Write-Host " Proceso: $processManager" -ForegroundColor White
                Write-Host " Puerto: $port" -ForegroundColor White
                Write-Host ""

                # ─── 5. Consultar estado del servidor ────────
                Write-Host " ─── Estado del servidor ───" -ForegroundColor Cyan

                $reportScript = @"
#!/bin/bash
# Versión actual (symlink current)
if [ -L "$remoteRoot/$appName/current" ]; then
    CURRENT=`$(readlink "$remoteRoot/$appName/current" | xargs basename)
    echo "CURRENT:`$CURRENT"
else
    echo "CURRENT:none"
fi
 
# Release destino ya existe?
if [ -d "$remoteRoot/$appName/releases/$release" ]; then
    echo "RELEASE:exists"
else
    echo "RELEASE:new"
fi
 
# Estado del servicio
if [ "$processManager" = "systemd" ]; then
    if systemctl is-active --quiet $appName 2>/dev/null; then
        echo "SERVICE:running"
    elif systemctl is-enabled --quiet $appName 2>/dev/null; then
        echo "SERVICE:stopped"
    else
        echo "SERVICE:not-configured"
    fi
else
    if pm2 describe $appName >/dev/null 2>&1; then
        STATUS=`$(pm2 describe $appName 2>/dev/null | grep status | head -1 | awk '{print `$4}')
        echo "SERVICE:`$STATUS"
    else
        echo "SERVICE:not-configured"
    fi
fi
"@


                $tmpLocal = New-UnixTempFile -Content $reportScript -Prefix "psdevops_report_nodeapi_"
                try {
                    $remoteName = [IO.Path]::GetFileName($tmpLocal)
                    $remotePath = "/tmp/$remoteName"

                    & scp -i $privateKeyPath -P $sshPort $tmpLocal "$($user)@$($ip):$remotePath" 2>&1 | Out-Null
                    if ($LASTEXITCODE -ne 0) { throw "Error al conectar con el servidor (scp exit: $LASTEXITCODE)" }

                    $remoteCmd = "bash $remotePath ; rc=`$?; rm -f $remotePath; exit `$rc"
                    $output = & ssh -i $privateKeyPath -p $sshPort "$($user)@$($ip)" $remoteCmd 2>&1
                } finally {
                    Remove-Item -LiteralPath $tmpLocal -ErrorAction SilentlyContinue
                }

                # Parsear salida
                $currentVersion = 'desconocido'
                $releaseStatus = 'desconocido'
                $serviceStatus = 'desconocido'

                foreach ($line in $output) {
                    if ($line -match '^CURRENT:(.+)$') { $currentVersion = $Matches[1] }
                    if ($line -match '^RELEASE:(.+)$') { $releaseStatus = $Matches[1] }
                    if ($line -match '^SERVICE:(.+)$') { $serviceStatus = $Matches[1] }
                }

                if ($currentVersion -eq 'none') {
                    Write-Host " Current: (primer deploy)" -ForegroundColor Yellow
                } else {
                    Write-Host " Current: $currentVersion" -ForegroundColor White
                }

                if ($releaseStatus -eq 'exists') {
                    Write-Host " Release: $release ya existe (se sobreescribirá)" -ForegroundColor Yellow
                } else {
                    Write-Host " Release: $release (nueva)" -ForegroundColor Green
                }

                if ($serviceStatus -eq 'running') {
                    Write-Host " Servicio: $processManager activo (se reiniciará)" -ForegroundColor White
                } elseif ($serviceStatus -eq 'not-configured') {
                    Write-Host " Servicio: se creará ($processManager)" -ForegroundColor Green
                } else {
                    Write-Host " Servicio: $serviceStatus" -ForegroundColor Yellow
                }

                # ─── 6. Acciones que realizará -Publish ──────
                Write-Host ""
                Write-Host " ─── Acciones que realizará -Publish ───" -ForegroundColor Cyan
                Write-Host " 1. Compilar TypeScript (npm ci + tsc)" -ForegroundColor White
                Write-Host " 2. Empaquetar artefactos en tar.gz" -ForegroundColor White
                Write-Host " 3. Subir tar.gz + .env.production a ${ip}:/tmp/" -ForegroundColor White
                Write-Host " 4. Instalar en ${remoteRoot}/${appName}/releases/${release}/" -ForegroundColor White
                Write-Host " 5. Actualizar symlink current → $release" -ForegroundColor White
                Write-Host " 6. Configurar/reiniciar servicio ($processManager)" -ForegroundColor White
                Write-Host " 7. Healthcheck en puerto $port" -ForegroundColor White
                Write-Host ""
            }
        }
    }
}