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' } |