Private/PublishHelpers.ps1
|
# PublishHelpers.ps1 # Funciones helper compartidas para despliegues y publicación remota <# .SYNOPSIS Extrae un valor de un archivo YAML simple. .DESCRIPTION Busca una clave en formato "key: value" y retorna el valor sin comillas. No es un parser YAML completo, solo para casos simples. .PARAMETER Content Array de líneas del archivo YAML. .PARAMETER Key Nombre de la clave a buscar. .EXAMPLE Get-YamlValue $content 'name' #> function Get-YamlValue { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string[]]$Content, [Parameter(Mandatory=$true)] [string]$Key ) $line = $Content | Where-Object { $_ -match "^\s*$Key\s*:" } | Select-Object -First 1 if (-not $line) { return $null } $value = ($line -replace "^\s*$Key\s*:\s*", '').Trim() $value = $value -replace '^["'']|["'']$', '' # Remover comillas return $value } <# .SYNOPSIS Normaliza el basePath de un API para construir URLs. .DESCRIPTION Garantiza slash inicial y elimina slashes finales. Una cadena vacia o "/" retorna vacio (la URL resultante queda en la raiz, retrocompatible con APIs que exponen /health sin basePath). .PARAMETER BasePath basePath declarado, p. ej. "api/v1" o "/api/v1/". .EXAMPLE Format-ApiBasePath -BasePath 'api/v1' # => '/api/v1' Format-ApiBasePath -BasePath '/api/v1/' # => '/api/v1' Format-ApiBasePath -BasePath '' # => '' #> function Format-ApiBasePath { [CmdletBinding()] param( [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$BasePath = '' ) $value = $BasePath.Trim().TrimEnd('/') if (-not $value) { return '' } if (-not $value.StartsWith('/')) { $value = "/$value" } return $value } <# .SYNOPSIS Resuelve el basePath efectivo del API segun la precedencia del ecosistema. .DESCRIPTION Precedencia (single source of truth primero): 1. package.json -> "modularApi": { "basePath": "..." } (convencion modular_api) 2. publish.yaml -> api.basePath (override explicito) 3. "" (raiz) (retrocompatible) El valor retornado ya viene normalizado por Format-ApiBasePath. .PARAMETER PackageJson Objeto de package.json ya parseado (ConvertFrom-Json). .PARAMETER PublishConfig Objeto de publish.yaml ya parseado (ConvertFrom-Yaml). .EXAMPLE $apiBasePath = Resolve-ApiBasePath -PackageJson $pkg -PublishConfig $deployConfig #> function Resolve-ApiBasePath { [CmdletBinding()] param( [Parameter(Mandatory=$false)] $PackageJson, [Parameter(Mandatory=$false)] $PublishConfig ) $raw = '' if ($PackageJson -and $PackageJson.modularApi -and $PackageJson.modularApi.basePath) { $raw = [string]$PackageJson.modularApi.basePath } elseif ($PublishConfig -and $PublishConfig.api -and $PublishConfig.api.basePath) { $raw = [string]$PublishConfig.api.basePath } return Format-ApiBasePath -BasePath $raw } <# .SYNOPSIS Resuelve la ruta del archivo de configuracion de despliegue del proyecto. .DESCRIPTION Busca publish.yaml (nombre actual, coherente con el cmdlet Publish-NodeApi) y, si no existe, cae al nombre anterior deploy.yaml marcandolo como legacy para que el caller emita el aviso de deprecacion. .PARAMETER ProjectRoot Directorio raiz del proyecto. .EXAMPLE $cfg = Resolve-PublishConfigPath -ProjectRoot (Get-Location).Path if (-not $cfg.Path) { throw "..." } if ($cfg.IsLegacy) { Deny-DeprecatedUsage -Cmdlet $c -What 'deploy.yaml' -UseInstead 'publish.yaml' -Since '6.0.0' } #> function Resolve-PublishConfigPath { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$ProjectRoot ) $publishPath = Join-Path $ProjectRoot 'publish.yaml' if (Test-Path $publishPath) { return @{ Path = $publishPath; IsLegacy = $false } } $legacyPath = Join-Path $ProjectRoot 'deploy.yaml' if (Test-Path $legacyPath) { return @{ Path = $legacyPath; IsLegacy = $true } } return @{ Path = $null; IsLegacy = $false } } <# .SYNOPSIS Lee un archivo .env y extrae todas las variables de entorno. .DESCRIPTION Parsea un archivo .env ignorando comentarios y líneas vacías. Retorna un hashtable con las variables y extrae PORT si existe. .PARAMETER Path Ruta al archivo .env. .PARAMETER DefaultPort Puerto por defecto si no se encuentra PORT en .env (default: 8080). .EXAMPLE $config = Read-DotEnv "D:\proyecto\.env" $envVars = $config.Env $port = $config.Port #> function Read-DotEnv { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$Path, [Parameter()] [int]$DefaultPort = 8080 ) $env = @{} $port = $DefaultPort if (-not (Test-Path $Path)) { return @{ Env = $env; Port = $port } } Get-Content $Path | Where-Object { $_ -and ($_ -notmatch '^\s*#') } | ForEach-Object { if ($_ -match '^\s*([^=]+)\s*=\s*(.*)$') { $key = $Matches[1].Trim() $value = $Matches[2].Trim() # Remover comillas exteriores if ($value -match '^["''](.+)["'']$') { $value = $Matches[1] } $env[$key] = $value # Extraer PORT si es numérico if ($key -eq 'PORT' -and $value -match '^\d+$') { $port = [int]$value } } } return @{ Env = $env Port = $port } } <# .SYNOPSIS Valida y obtiene una distribución WSL disponible. .DESCRIPTION Verifica que WSL esté instalado, busca la distro preferida o hace fallback a la primera distro Ubuntu disponible. .PARAMETER Preferred Nombre de la distro preferida (default: "Ubuntu"). .EXAMPLE $distro = Get-ValidWSLDistro -Preferred "Ubuntu" #> function Get-ValidWSLDistro { [CmdletBinding()] param( [Parameter()] [string]$Preferred = "Ubuntu" ) # Verificar que wsl.exe existe if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { throw "wsl.exe no está disponible en PATH. Habilita WSL en Windows. Ejecuta 'wsl -l -v' para comprobar." } # Obtener listado de distros instaladas $wslListRaw = & wsl.exe --list --quiet 2>&1 $distros = $wslListRaw -replace '\p{C}', '' -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } if (-not $distros -or $distros.Count -eq 0) { throw "No hay distribuciones WSL instaladas. Instala una con: wsl --install -d $Preferred" } # Verificar si la preferida existe if ($distros -contains $Preferred) { return $Preferred } # Fallback a cualquier Ubuntu $ubuntu = $distros | Where-Object { $_ -like 'Ubuntu*' } | Select-Object -First 1 if ($ubuntu) { Write-Host "Advertencia: distro '$Preferred' no encontrada. Usando '$ubuntu' (fallback)." -ForegroundColor Yellow return $ubuntu } # Si no hay Ubuntu, fallar con información útil $available = $distros -join ', ' throw "Distro '$Preferred' no encontrada. Distros instaladas: $available. Instala la correcta con: wsl --install -d $Preferred" } <# .SYNOPSIS Construye el string de variables de entorno para PM2. .DESCRIPTION Convierte un hashtable de variables de entorno en una cadena de parámetros --env KEY='VALUE' para PM2, escapando comillas correctamente. .PARAMETER EnvVars Hashtable con las variables de entorno. .EXAMPLE $envString = New-PM2EnvString $EnvVars # Resultado: "--env PORT='4321' --env DB_HOST='localhost' ..." #> function New-PM2EnvString { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [hashtable]$EnvVars ) if ($EnvVars.Count -eq 0) { return "" } $parts = @() foreach ($key in $EnvVars.Keys) { $value = $EnvVars[$key] # Escapar comillas simples para bash $escapedValue = $value -replace "'", "'\\''" $parts += "--env $key='$escapedValue'" } return $parts -join " " } <# .SYNOPSIS Crea un archivo temporal con contenido UTF-8 sin BOM y line endings Unix. .DESCRIPTION Helper para crear scripts bash temporales desde PowerShell asegurando la codificación correcta (UTF-8 sin BOM, LF line endings). .PARAMETER Content Contenido del archivo. .PARAMETER Prefix Prefijo para el nombre del archivo temporal (default: "psdevops_"). .EXAMPLE $tmpFile = New-UnixTempFile -Content $scriptContent -Prefix "build_" #> function New-UnixTempFile { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$Content, [Parameter()] [string]$Prefix = "psdevops_" ) # Normalizar a LF $unixContent = $Content -replace "`r`n", "`n" -replace "`r", "`n" # Crear archivo temporal $tmpPath = [IO.Path]::Combine([IO.Path]::GetTempPath(), "${Prefix}{0}.sh" -f ([guid]::NewGuid().ToString())) # Escribir como UTF-8 sin BOM $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($tmpPath, $unixContent, $utf8NoBom) return $tmpPath } <# .SYNOPSIS Ejecuta un script bash en el servidor remoto vía SSH. .DESCRIPTION Sube un script temporal al servidor remoto, lo ejecuta y lo elimina. Maneja correctamente la codificación y limpieza de archivos temporales. .PARAMETER ScriptContent Contenido del script bash a ejecutar. .PARAMETER User Usuario SSH. .PARAMETER IP IP del servidor. .PARAMETER Port Puerto SSH. .PARAMETER KeyPath Ruta a la clave privada SSH. .PARAMETER ScriptPrefix Prefijo para el archivo temporal (default: "psdevops_remote_"). .EXAMPLE Invoke-RemoteScript -ScriptContent $installScript -User "user" -IP "192.168.1.1" -Port 22 -KeyPath "~/.ssh/id_rsa" #> function Invoke-RemoteScript { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$ScriptContent, [Parameter(Mandatory=$true)] [string]$User, [Parameter(Mandatory=$true)] [string]$IP, [Parameter(Mandatory=$true)] [int]$Port, [Parameter(Mandatory=$true)] [string]$KeyPath, [Parameter()] [string]$ScriptPrefix = "psdevops_remote_" ) $tmpLocal = New-UnixTempFile -Content $ScriptContent -Prefix $ScriptPrefix try { $remoteName = [IO.Path]::GetFileName($tmpLocal) $remotePath = "/tmp/$remoteName" # Subir script (suprimir salida) & scp -i $KeyPath -P $Port $tmpLocal "$($User)@$($IP):$remotePath" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "Error al subir script al servidor remoto (scp exit code: $LASTEXITCODE)" } # Ejecutar y eliminar (capturar salida completa) # IMPORTANTE: stderr (warnings) no son errores, solo el exit code != 0 $remoteCmd = "bash $remotePath ; rc=`$?; rm -f $remotePath; exit `$rc" $ErrorActionPreference = 'Continue' # Permitir stderr sin detener $output = & ssh -i $KeyPath -p $Port "$($User)@$($IP)" $remoteCmd 2>&1 $exitCode = $LASTEXITCODE $ErrorActionPreference = 'Stop' # Restaurar # Siempre mostrar salida (incluye warnings y mensajes informativos) if ($output) { $output | ForEach-Object { $line = $_.ToString() # Colorear warnings en amarillo, errores en rojo, resto normal if ($line -match '^WARNING:') { Write-Host $line -ForegroundColor Yellow } elseif ($line -match '^ERROR:') { Write-Host $line -ForegroundColor Red } else { Write-Host $line } } } return $exitCode } finally { Remove-Item -LiteralPath $tmpLocal -ErrorAction SilentlyContinue } } <# .SYNOPSIS Carga un script bash externo y reemplaza placeholders. .DESCRIPTION Lee un archivo .sh desde el directorio scripts/, reemplaza variables tipo __PLACEHOLDER__ con valores reales, y retorna el contenido procesado. .PARAMETER ScriptName Nombre del archivo de script (ej: "Build-DartBinary.sh"). .PARAMETER Placeholders Hashtable con los valores a reemplazar. Keys deben incluir __ antes y después. .EXAMPLE $script = Get-BashScript -ScriptName "Build-DartBinary.sh" -Placeholders @{ '__WSLPROJECT__' = '/mnt/d/myproject' '__WSLWINOUT__' = '/mnt/c/temp/output' } #> function Get-BashScript { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$ScriptName, [Parameter(Mandatory=$true)] [hashtable]$Placeholders ) # Construir ruta al script $scriptPath = Join-Path $PSScriptRoot "scripts\$ScriptName" if (-not (Test-Path $scriptPath)) { throw "Script no encontrado: $scriptPath" } # Leer contenido $content = Get-Content $scriptPath -Raw # Reemplazar cada placeholder foreach ($key in $Placeholders.Keys) { $value = $Placeholders[$key] $content = $content -replace [regex]::Escape($key), $value } return $content } # ═══════════════════════════════════════════════════════════════════ # ADR 0003 — No-build runtime helpers (build:false / any Node API) # ═══════════════════════════════════════════════════════════════════ <# .SYNOPSIS Resuelve el modo de runtime (build) y el entrypoint desde publish.yaml. .DESCRIPTION ADR 0003. Precedencia retrocompatible: - build: runtime.build (si se declara) > $true (default, flujo TypeScript actual). - entrypoint: runtime.entrypoint (explicito) > 'dist/main.js' (build:true) > 'server.js' (build:false). El entrypoint se normaliza quitando un './' inicial. Es la ruta relativa a la release que ejecuta el proceso (current/<entrypoint>). .PARAMETER PublishConfig Objeto de publish.yaml ya parseado (ConvertFrom-Yaml) o hashtable equivalente. .EXAMPLE $rt = Resolve-NodeRuntime -PublishConfig $deployConfig if (-not $rt.Build) { # saltar tsconfig/build, empaquetar fuente } #> function Resolve-NodeRuntime { [CmdletBinding()] param( [Parameter(Mandatory = $false)] $PublishConfig ) $runtime = if ($PublishConfig) { $PublishConfig.runtime } else { $null } $build = $true if ($runtime -and ($null -ne $runtime.build)) { $build = [bool]$runtime.build } $entrypoint = $null if ($runtime -and $runtime.entrypoint) { $entrypoint = [string]$runtime.entrypoint } if (-not $entrypoint) { $entrypoint = if ($build) { 'dist/main.js' } else { 'server.js' } } # Normalizar: quitar './' inicial y backslashes -> '/' $entrypoint = ($entrypoint -replace '\\', '/') -replace '^\./', '' # sharedPaths (ADR 0003): archivos/directorios que la app necesita en runtime pero # que NO estan versionados en git (secretos, claves, certs) y por tanto NO viajan en # el tarball (git archive HEAD). Se stagean una vez en $REMOTE_ROOT/$NAME/shared/<path> # y el instalador los symlinkea dentro de cada release. Se normalizan como el entrypoint. $sharedPaths = @() if ($runtime -and $runtime.sharedPaths) { foreach ($p in @($runtime.sharedPaths)) { $norm = ((([string]$p) -replace '\\', '/') -replace '^\./', '').Trim().TrimEnd('/') if ($norm) { $sharedPaths += $norm } } } return @{ Build = $build Entrypoint = $entrypoint SharedPaths = $sharedPaths } } <# .SYNOPSIS Lanza un error de deprecación con la información necesaria para migrar (ADR 0012). .DESCRIPTION Una deprecación falla; no avisa y continúa. Un aviso que sigue funcionando perpetúa la deuda, porque nadie migra lo que no le impide trabajar, y acaba leyéndose como ruido de fondo. Este helper es la única vía de producir ese error, para que ningún mensaje quede a criterio de quien escribe el `throw`. El mensaje debe bastar para migrar sin abrir la documentación: qué se usó, desde cuándo está retirado, qué usar en su lugar y dónde corregirlo. .PARAMETER Cmdlet Nombre del cmdlet que rechaza el uso. Va primero para que el error diga de dónde salió. .PARAMETER What El elemento deprecado, tal como el usuario lo escribió ('-Publish', 'MACSS_DEPLOY_SERVER'). .PARAMETER UseInstead El reemplazo exacto. .PARAMETER Since Versión en la que se retiró. .PARAMETER Where Archivo donde hay que hacer la corrección. Opcional. .PARAMETER Detail Una línea extra de contexto. Opcional. .PARAMETER Reference ADR u origen de la decisión. Opcional. .EXAMPLE Deny-DeprecatedUsage -Cmdlet 'Publish-NodeApi' -What 'MACSS_DEPLOY_SERVER' ` -UseInstead 'MACSS_DEPLOY_SSH_ALIAS' -Since '6.0.0' -Where '.env.production' -Reference 'ADR 0010' #> function Deny-DeprecatedUsage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Cmdlet, [Parameter(Mandatory = $true)] [string]$What, [Parameter(Mandatory = $true)] [string]$UseInstead, [Parameter(Mandatory = $true)] [string]$Since, [Parameter()] [string]$Where, [Parameter()] [string]$Detail, [Parameter()] [string]$Reference ) $lines = @("${Cmdlet}: '$What' se retiró en $Since.") if ($Where) { $lines += " Use '$UseInstead' en su lugar, en '$Where'." } else { $lines += " Use '$UseInstead' en su lugar." } if ($Detail) { $lines += " $Detail" } if ($Reference) { $lines += " Ver $Reference." } throw ($lines -join [Environment]::NewLine) } <# .SYNOPSIS Resuelve el alias SSH de destino desde el env file (ADR 0004, ADR 0010). .DESCRIPTION Concentra el patrón que ADR 0004 fijó y que hasta 6.0.0 cada cmdlet resolvía por su cuenta: el destino sale de `MACSS_DEPLOY_SSH_ALIAS` en el env file gitignored elegido con `-EnvFile`, nunca de un archivo versionado. Un alias SSH es machine-local; versionarlo hace que el repo no sea portable y obliga a editar un archivo del repo para cambiar de entorno. Lo consumen los tres cmdlets cuyo destino es una máquina a la que se salta: `Publish-NodeApi`, `Publish-FlutterWeb` y `Publish-DockerStack`. Los de base de datos no lo usan: su destino es un endpoint de red, no un host al que saltar (ADR 0011). Los mecanismos deprecados **fallan**, no se aceptan (ADR 0012). Y fallan también cuando coexisten con la clave nueva: tener las dos es justo el estado ambiguo que hay que eliminar. .PARAMETER ProjectRoot Raíz del proyecto donde se busca el env file. .PARAMETER EnvFile Env file que selecciona el entorno. Por defecto '.env': producción siempre es explícita. .PARAMETER LegacyServer Valor de 'server:' leído del archivo versionado, si lo hubiera. Su sola presencia es un error; se recibe para poder nombrarlo en el mensaje de migración. .PARAMETER Cmdlet Nombre del cmdlet que llama, para que el error diga de dónde salió. #> function Resolve-DeployTargetFromEnv { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [string]$ProjectRoot, [Parameter()] [string]$EnvFile = '.env', [Parameter()] [string]$LegacyServer, [Parameter(Mandatory = $true)] [string]$Cmdlet ) $envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $ProjectRoot $EnvFile } $vars = @{} if (Test-Path -LiteralPath $envPath) { $vars = (Read-DotEnv -Path $envPath).Env } $alias = if ($vars['MACSS_DEPLOY_SSH_ALIAS']) { "$($vars['MACSS_DEPLOY_SSH_ALIAS'])".Trim() } else { '' } $legacy = if ($vars['MACSS_DEPLOY_SERVER']) { "$($vars['MACSS_DEPLOY_SERVER'])".Trim() } else { '' } # Los deprecados se comprueban ANTES de resolver: si el env trae la clave nueva y ademas # arrastra la vieja, el estado es ambiguo y resolverlo en silencio dejaria archivos a medio # migrar sin que nadie se entere. if ($legacy) { Deny-DeprecatedUsage -Cmdlet $Cmdlet -What 'MACSS_DEPLOY_SERVER' ` -UseInstead 'MACSS_DEPLOY_SSH_ALIAS' -Since '6.0.0' -Where $EnvFile ` -Detail 'El valor no cambia: sigue siendo el alias de ~/.ssh/config.' ` -Reference 'ADR 0010' } if ($LegacyServer) { Deny-DeprecatedUsage -Cmdlet $Cmdlet -What "server: en el archivo versionado" ` -UseInstead 'MACSS_DEPLOY_SSH_ALIAS' -Since '6.0.0' -Where $EnvFile ` -Detail "Mueva el valor '$LegacyServer' al env file y borre la clave 'server' del archivo versionado." ` -Reference 'ADR 0010' } if (-not $alias) { throw "${Cmdlet}: no hay destino de despliegue. Falta 'MACSS_DEPLOY_SSH_ALIAS' en '$EnvFile'. " + "Agregue 'MACSS_DEPLOY_SSH_ALIAS=<alias de ~/.ssh/config>' a ese archivo." } return $alias } <# .SYNOPSIS Resuelve el nombre de la base SQL Server desde el `.sqlproj` (ADR 0011). .DESCRIPTION El nombre de la base no es *dónde* despliegas: es *qué* despliegas. Por eso vive en el archivo de proyecto versionado y no en el env file, que existe para expresar diferencias entre entornos. La medición que motivó la decisión: `DB_NAME` era idéntico en `.env` y `.env.production` en todos los repos de la organización — identidad duplicada, no configuración. `DB_NAME` sobrevive como **override explícito**, por las DB Tier-1 desechables (`dev_<nombre>`), donde el mismo dacpac se publica a una base con otro nombre. Derivar a secas eliminaría ese flujo. Dos usos de `DB_NAME` se rechazan: - **Redundante** (repite el nombre del proyecto): duplicación muerta, se pide borrarla. - **Difiere solo en mayúsculas**: en una collation case-insensitive da igual; en una case-sensitive, no. El cmdlet no elige — adivinar sería cambiar el objetivo de un despliegue de producción en silencio. .OUTPUTS PSCustomObject con Name (el objetivo real), ProjectName (lo que declara el .sqlproj) e IsOverride. #> function Resolve-SqlDbIdentity { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ProjectRoot, [Parameter()] [string]$EnvFile = '.env' ) $proj = Get-ChildItem -LiteralPath $ProjectRoot -Filter '*.sqlproj' -File -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $proj) { throw "No se encontró ningún archivo .sqlproj en '$ProjectRoot'. Ejecute este cmdlet desde el directorio del SQL Project." } $content = Get-Content -LiteralPath $proj.FullName -Raw -Encoding UTF8 $projectName = '' if ($content -match '<Name>\s*([^<]+?)\s*</Name>') { $projectName = $Matches[1].Trim() } if (-not $projectName) { throw "'$($proj.Name)' no declara <Name>. La identidad de la base se lee de ahí (ADR 0011): " + "agregue <Name>NombreDeLaBase</Name> dentro de su PropertyGroup." } $envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $ProjectRoot $EnvFile } $override = '' if (Test-Path -LiteralPath $envPath) { $vars = (Read-DotEnv -Path $envPath).Env if ($vars['DB_NAME']) { $override = "$($vars['DB_NAME'])".Trim() } } if ($override) { if ($override -ceq $projectName) { throw "'$($proj.Name)' ya declara la base como '$projectName'. Borre 'DB_NAME' de '$EnvFile': " + "el nombre se lee del .sqlproj y tenerlo en dos sitios los deja divergir (ADR 0011)." } if ($override -ieq $projectName) { throw "Conflicto de mayúsculas entre el proyecto y el env, y la diferencia importa en una " + "collation case-sensitive. '$($proj.Name)' declara <Name>$projectName</Name>; '$EnvFile' " + "declara DB_NAME=$override. Si el nombre real de la base es '$override', corrija <Name> " + "en el .sqlproj; si es '$projectName', borre DB_NAME. No se elige por usted (ADR 0011)." } return [pscustomobject]@{ Name = $override ProjectName = $projectName IsOverride = $true } } return [pscustomobject]@{ Name = $projectName ProjectName = $projectName IsOverride = $false } } <# .SYNOPSIS Resuelve el nombre de la base PostgreSQL desde `pgschema.yaml` (ADR 0011). .DESCRIPTION Misma frontera que en SQL Server: identidad en el archivo de proyecto versionado, conexión en el env file gitignored. `pgschema` no produce un artefacto compilado que cargue identidad como el dacpac, pero la identidad no necesita un binario: necesita un lugar versionado, y `pgschema.yaml` ya lo es. `PGDATABASE` en el env **falla**. No hay override aquí: si aparece un caso Tier-1 en PostgreSQL se decidirá entonces, con el caso delante. .OUTPUTS PSCustomObject con Name. #> function Resolve-PgDbIdentity { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ProjectRoot, [Parameter()] [string]$EnvFile = '.env' ) $yamlPath = Join-Path $ProjectRoot 'pgschema.yaml' if (-not (Test-Path -LiteralPath $yamlPath)) { throw "No se encontró pgschema.yaml en '$ProjectRoot'. Ejecute 'Invoke-PgSchema -Init' primero." } # El deprecado se comprueba antes de leer el yaml: si estan los dos, el estado es ambiguo. $envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $ProjectRoot $EnvFile } if (Test-Path -LiteralPath $envPath) { $vars = (Read-DotEnv -Path $envPath).Env if ($vars['PGDATABASE']) { Deny-DeprecatedUsage -Cmdlet 'Invoke-PgSchema' -What 'PGDATABASE' ` -UseInstead 'database:' -Since '6.0.0' -Where 'pgschema.yaml' ` -Detail "El nombre de la base es identidad del proyecto, no configuracion de entorno: mueva el valor a 'database:' en pgschema.yaml y borrelo de '$EnvFile'." ` -Reference 'ADR 0011' } } $yaml = Get-Content -LiteralPath $yamlPath -Raw -Encoding UTF8 $name = '' if ($yaml -match '(?m)^\s*database\s*:\s*(.+?)\s*$') { $name = $Matches[1].Trim().Trim("'", '"') } if (-not $name) { throw "pgschema.yaml no declara 'database:'. La identidad de la base se lee de ahí (ADR 0011): " + "agregue 'database: <nombre>' al inicio del archivo." } return [pscustomobject]@{ Name = $name } } <# .SYNOPSIS Quita del contenido del env las claves deploy-time (MACSS_DEPLOY_*) antes de subirlo (ADR 0004). .DESCRIPTION Las claves `MACSS_DEPLOY_*` son metadato de despliegue (p.ej. el destino), no config runtime de la app. Se leen localmente pero NO deben viajar al env de la app en el servidor. Filtra por linea, preservando comentarios, lineas vacias y el resto de claves tal cual (orden y valores intactos). Solo elimina claves cuyo NOMBRE empieza con `MACSS_DEPLOY_`. .PARAMETER Lines Lineas del env file (Get-Content, sin -Raw). .EXAMPLE $clean = Remove-DeployOnlyEnvKeys -Lines (Get-Content $envFile) #> function Remove-DeployOnlyEnvKeys { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$Lines ) return @($Lines | Where-Object { $_ -notmatch '^\s*MACSS_DEPLOY_[A-Za-z0-9_]*\s*=' }) } <# .SYNOPSIS Asegura que un env file declare MACSS_DEPLOY_SSH_ALIAS (ADR 0004), idempotente. .DESCRIPTION Usado por -Init. Si el archivo no existe lo crea con una plantilla mínima (incluye MACSS_DEPLOY_SSH_ALIAS=, PORT, NODE_ENV). Si existe pero no tiene la clave, la agrega al final. Si ya la tiene, no toca nada. Retorna 'created' | 'appended' | 'exists'. .PARAMETER Path Ruta del env file. .PARAMETER EnvLabel Etiqueta para el comentario del archivo nuevo (p.ej. 'producción'). Opcional. .EXAMPLE Add-EnvDeployKey -Path (Join-Path $cwd '.env') -EnvLabel 'default (dev/pre-prod)' #> function Add-EnvDeployKey { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $false)] [string]$EnvLabel = '' ) $keyComment = '# Destino del despliegue: alias de ~/.ssh/config (config local, per-máquina).' if (-not (Test-Path $Path)) { $header = if ($EnvLabel) { "# Env file — $EnvLabel." } else { '# Env file.' } $tpl = @" $header Se copia al servidor como .env del release # (sin las claves MACSS_DEPLOY_*). NO versionar (está en .gitignore). $keyComment MACSS_DEPLOY_SSH_ALIAS= PORT=8080 NODE_ENV=production "@ Set-Content -Path $Path -Value $tpl -Encoding UTF8 return 'created' } $content = Get-Content $Path -Raw if ($content -match '(?m)^\s*MACSS_DEPLOY_SSH_ALIAS\s*=') { return 'exists' } $sep = if ($content -and -not $content.EndsWith("`n")) { "`n" } else { '' } Add-Content -Path $Path -Value "$sep`n$keyComment`nMACSS_DEPLOY_SSH_ALIAS=" return 'appended' } <# .SYNOPSIS Compone el identificador de release: v{version}+{shortSha} (ADR 0003). .DESCRIPTION Toma la version de package.json (descartando cualquier build metadata previo tras '+') y le adjunta el sha corto de git para identificar univocamente cada despliegue, incluso cuando la version de package.json es estatica. .EXAMPLE Get-ReleaseId -Version '1.0.0' -ShortSha 'abc1234' # => v1.0.0+abc1234 #> function Get-ReleaseId { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Version, [Parameter(Mandatory = $true)] [string]$ShortSha ) $baseVersion = ($Version -split '\+')[0] return "v$baseVersion+$ShortSha" } <# .SYNOPSIS Indica si el arbol de trabajo de git esta limpio (ADR 0003). .DESCRIPTION En modo build:false el tarball se arma desde HEAD (git archive), asi que un arbol sucio desplegaria algo distinto de lo que se ve. Este guard permite que -Apply exija un arbol limpio (salvo -AllowDirty). Retorna $true si no hay cambios pendientes. .PARAMETER Path Directorio dentro del repo git a inspeccionar. .EXAMPLE if (-not (Test-CleanWorktree -Path $cwd)) { throw "Commit or use -AllowDirty" } #> function Test-CleanWorktree { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path ) # Acotado al subarbol de $Path con el pathspec '-- .': en un monorepo, # Publish-NodeApi corre desde el subdir del componente (p. ej. code/api) y el # guard NO debe bloquearse por cambios sin commitear en OTROS componentes # (code/db, code/app, docs...). Cuando $Path es la raiz del repo, '-- .' # abarca todo el repo => comportamiento identico al anterior. $status = & git -C $Path status --porcelain -- . 2>$null return [string]::IsNullOrWhiteSpace(($status | Out-String)) } <# .SYNOPSIS Empaqueta como tar el subarbol versionado (HEAD) del directorio indicado, con sus archivos en la RAIZ del tar. Agnostico a la profundidad del subdir en el repo. .DESCRIPTION En build:false el tarball se arma con 'git archive' desde HEAD. En un monorepo el componente vive en un subdir (p. ej. code/api), asi que hay que archivar ese SUBARBOL con sus archivos en la raiz del tar. Clave: 'git archive' se corre desde el TOPLEVEL del repo. Si se corriera con -C en el subdir, git resuelve 'HEAD:<prefix>' relativo al cwd (=> <prefix>/<prefix>, arbol inexistente => tar VACIO => el entrypoint "no aparece"). Con --show-prefix (ruta raiz->cwd) y --show-toplevel (raiz del repo), el empaquetado funciona igual sin importar cuantas carpetas arriba este el .git. .PARAMETER Path Directorio del componente a empaquetar (desde donde se corre Publish-NodeApi). .PARAMETER OutTar Ruta del archivo .tar de salida. .EXAMPLE Export-GitSubtreeTar -Path (Get-Location) -OutTar $srcTar #> function Export-GitSubtreeTar { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$OutTar ) $prefix = "$(& git -C $Path rev-parse --show-prefix 2>$null)".Trim() $toplevel = "$(& git -C $Path rev-parse --show-toplevel 2>$null)".Trim() if (-not $toplevel) { $toplevel = $Path } $treeish = if ($prefix) { "HEAD:$($prefix.TrimEnd('/'))" } else { 'HEAD' } & git -C $toplevel archive --format=tar -o $OutTar $treeish if ($LASTEXITCODE -ne 0 -or -not (Test-Path $OutTar)) { throw "git archive fallo (treeish=$treeish, toplevel=$toplevel)." } } <# .SYNOPSIS Decide como instalar node_modules de produccion segun el SO host (ADR 0003). .DESCRIPTION Los binarios nativos (p. ej. oracledb thick) se compilan por plataforma; un `npm ci` en Windows produce un binario que no carga en el servidor Linux. En un host Windows la instalacion se enruta a WSL para obtener binarios Linux; en Linux se instala nativo. Helper de decision puro (sin efectos), testeable de forma unitaria. .PARAMETER IsWindowsHost $true si el host es Windows. .EXAMPLE $plan = Get-ProdModulesPlan -IsWindowsHost $IsWindows # 'wsl' | 'native' #> function Get-ProdModulesPlan { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [bool]$IsWindowsHost ) if ($IsWindowsHost) { return 'wsl' } return 'native' } <# .SYNOPSIS Genera el ecosystem de pm2 en formato JSON (config-as-data) desde la topologia declarativa de publish.yaml (ADR 0005). .DESCRIPTION pm2 carga JSON de forma nativa, sin la trampa CommonJS/ESM de un ecosystem.config.js en un proyecto "type":"module". El schema es la interseccion systemd n pm2: por proceso solo se admiten name, script, cwd y env. Las claves pm2-only (instances/cluster, cron_restart, watch, max_memory_restart) quedan fuera y se rechazan; para esos casos existe la valvula de escape ecosystem.config.cjs. Sin -Processes genera un unico app (single-process) desde -AppName/-Entrypoint. El env de cada proceso se fusiona SOBRE -RuntimeEnv (env no-secreto, versionado en publish.yaml). .PARAMETER RuntimeEnv Mapa de env no-secreto (runtime.env). Acepta hashtable o PSCustomObject (deserializado de YAML). .PARAMETER Processes Array de procesos (runtime.processes). Cada uno acepta name, script, cwd, env. .EXAMPLE New-Pm2EcosystemJson -AppName 'micro' -Entrypoint 'server.js' -RuntimeEnv @{ NODE_ENV = 'production' } #> function New-Pm2EcosystemJson { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$AppName, [Parameter(Mandatory = $true)][string]$Entrypoint, [object]$RuntimeEnv, [object]$Processes, [ValidateSet('always', 'on-failure', 'no')][string]$Restart = 'always', [int]$RestartDelaySec = 5 ) # Interseccion systemd n pm2 (ADR 0005): unicas claves portables por proceso. $allowedKeys = @('name', 'script', 'cwd', 'env') # Lee una clave de un hashtable o de un PSCustomObject (YAML deserializado). $getKey = { param($obj, $key) if ($null -eq $obj) { return $null } if ($obj -is [System.Collections.IDictionary]) { return $obj[$key] } $prop = $obj.PSObject.Properties[$key] if ($prop) { return $prop.Value } return $null } # Enumera las claves de un hashtable o PSCustomObject. $keysOf = { param($obj) if ($null -eq $obj) { return @() } if ($obj -is [System.Collections.IDictionary]) { return @($obj.Keys) } return @($obj.PSObject.Properties.Name) } # Fusiona dos mapas de env (base + override) preservando strings. $mergeEnv = { param($base, $override) $m = [ordered]@{} foreach ($src in @($base, $override)) { if ($null -eq $src) { continue } foreach ($k in (& $keysOf $src)) { $m[[string]$k] = [string](& $getKey $src $k) } } return $m } $procList = if ($Processes) { @($Processes) } else { @([ordered]@{ name = $AppName; script = $Entrypoint }) } $apps = [System.Collections.ArrayList]::new() foreach ($p in $procList) { $bad = @((& $keysOf $p) | Where-Object { $_ -notin $allowedKeys }) if ($bad.Count -gt 0) { throw "Clave(s) fuera de la interseccion systemd n pm2 en processes: $($bad -join ', '). El schema portable solo admite: $($allowedKeys -join ', '). Para configuracion pm2-only (instances/cluster, cron_restart, watch, max_memory_restart) use un ecosystem.config.cjs (valvula de escape, ADR 0005)." } $script = & $getKey $p 'script' if (-not $script) { $script = $Entrypoint } $mergedEnv = & $mergeEnv $RuntimeEnv (& $getKey $p 'env') $app = [ordered]@{ name = [string](& $getKey $p 'name') script = [string]$script autorestart = ($Restart -ne 'no') restart_delay = ($RestartDelaySec * 1000) } $cwd = & $getKey $p 'cwd' if ($cwd) { $app['cwd'] = [string]$cwd } if ($mergedEnv.Count -gt 0) { $app['env'] = $mergedEnv } [void]$apps.Add($app) } return ([ordered]@{ apps = @($apps) } | ConvertTo-Json -Depth 6) } |