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) { Write-Host "deploy.yaml esta deprecado..." }
#>

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
Resuelve el servidor destino del despliegue desde el env file elegido (ADR 0004).
 
.DESCRIPTION
El destino no es una propiedad del codigo sino config per-entorno/per-maquina: vive en el
env file gitignored (`.env`, `.env.production`, ...) bajo la clave namespaced
`MACSS_DEPLOY_SERVER` (un alias de ~/.ssh/config). El env file se elige con -EnvFile
(default `.env`), asi "que entorno" = "que archivo" y prod nunca es el default.
Falla claro si la clave no esta, en vez de desplegar a un destino silencioso/equivocado.
 
.PARAMETER EnvVars
Hashtable de variables ya parseadas del env file (Read-DotEnv .Env). Case-insensitive.
 
.PARAMETER EnvFilePath
Ruta del env file, solo para el mensaje de error (default '.env').
 
.EXAMPLE
$target = Resolve-DeployTarget -EnvVars $envConfig.Env -EnvFilePath $envFile
#>

function Resolve-DeployTarget {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        $EnvVars,

        [Parameter(Mandatory = $false)]
        [string]$EnvFilePath = '.env'
    )

    $target = ''
    if ($EnvVars -and $EnvVars['MACSS_DEPLOY_SERVER']) {
        $target = "$($EnvVars['MACSS_DEPLOY_SERVER'])".Trim()
    }

    if (-not $target) {
        throw "No hay destino de despliegue: falta 'MACSS_DEPLOY_SERVER' en '$EnvFilePath'. " +
              "Ejecute 'Publish-NodeApi -Init' o agregue 'MACSS_DEPLOY_SERVER=<alias de ~/.ssh/config>' al env file."
    }

    return $target
}

<#
.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_SERVER (ADR 0004), idempotente.
 
.DESCRIPTION
Usado por -Init. Si el archivo no existe lo crea con una plantilla mínima (incluye
MACSS_DEPLOY_SERVER=, 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_SERVER=
 
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_SERVER\s*=') {
        return 'exists'
    }

    $sep = if ($content -and -not $content.EndsWith("`n")) { "`n" } else { '' }
    Add-Content -Path $Path -Value "$sep`n$keyComment`nMACSS_DEPLOY_SERVER="
    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'
}