#Requires -RunAsAdministrator <# .SYNOPSIS Automatiza el montaje del entorno de desarrollo Windows para aplicaciones IVAP (EJIE). .DESCRIPTION Instala paquetes via Winget, configura el archivo hosts, crea accesos directos en el escritorio comun y lanza la configuracion interactiva de Git/GPG. Todos los pasos son idempotentes: una segunda ejecucion muestra [--] SKIP en los pasos ya completados. .NOTES Version : 1.0 Log : %USERPROFILE%\ivap-setup.log Requiere: Windows 10/11, winget, PowerShell 5.1+, ejecucion como Administrador #> Set-StrictMode -Version Latest $ErrorActionPreference = "Continue" # --------------------------------------------------------------------------- # Constantes globales # --------------------------------------------------------------------------- $script:LogFile = "$env:USERPROFILE\ivap-setup.log" $script:Summary = [ordered]@{} $script:GitScript = "https://statics.gobtech.lksnext.com/git/git.ps1" $script:CdnBase = "https://statics.gobtech.lksnext.com/ejie/ivap" $script:CdnEntorno = "https://statics.gobtech.lksnext.com/ejie/ivap/entorno" $script:MarkerDir = "C:\ProgramData\ivap" $script:GitLabUrl = "https://gitlks.gobtech.lksnext.com" $script:GitLabGroup = "ejie/ivap" $script:CloneDestDir = "C:\aplic" $script:Packages = @( [pscustomobject]@{ Name = "Notepad++"; Id = "Notepad++.Notepad++" } [pscustomobject]@{ Name = "Git"; Id = "Git.Git" } [pscustomobject]@{ Name = "GnuPG"; Id = "GnuPG.GnuPG" } [pscustomobject]@{ Name = "7-Zip"; Id = "7zip.7zip" } [pscustomobject]@{ Name = "SourceTree"; Id = "Atlassian.Sourcetree" } [pscustomobject]@{ Name = "DiffMerge"; Id = "SourceGear.DiffMerge" } [pscustomobject]@{ Name = "DBeaver"; Id = "DBeaver.DBeaver.Community" } [pscustomobject]@{ Name = "SoapUI"; Id = "SmartBear.SoapUI" } [pscustomobject]@{ Name = "TortoiseSVN"; Id = "TortoiseSVN.TortoiseSVN" } ) $script:HostsEntries = @( [pscustomobject]@{ Ip = "127.0.0.1"; Host = "local.ejiedes.net" } [pscustomobject]@{ Ip = "127.0.0.1"; Host = "desarrollo.jakina.ejiedes.net" } [pscustomobject]@{ Ip = "10.190.16.14"; Host = "anthillpro.jakina.ejiedes.net" } ) $script:Shortcuts = @( [pscustomobject]@{ Name = "Eclipse WL11 (Geremua)" Target = "C:\Program Files\eclipse WL11\eclipse.exe" RunAs = $false } [pscustomobject]@{ Name = "Eclipse WL14 (UDA)" Target = "C:\Program Files\eclipse WL14\eclipse.exe" RunAs = $false } [pscustomobject]@{ Name = "WebLogic WL11" Target = "C:\dominio_wls1036\dominio_desa\startWebLogic.cmd" RunAs = $false } [pscustomobject]@{ Name = "WebLogic WL14" Target = "C:\dominio_wls14110\dominio_desa\startWebLogic.cmd" RunAs = $false } [pscustomobject]@{ Name = "Crear enlaces (Admin)" Target = "C:\usr\Crear enlaces.bat" RunAs = $true } ) # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $line = "[$timestamp][$Level] $Message" Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 # No escribimos a consola desde aqui; la consola la gestionan Write-Section etc. } function Write-Section { param([string]$Title) $separator = "=" * 60 Write-Host "" Write-Host $separator -ForegroundColor Cyan Write-Host " $Title" -ForegroundColor Cyan Write-Host $separator -ForegroundColor Cyan Write-Log "=== $Title ===" "SECTION" } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green Write-Log $Message "OK" } function Write-Skip { param([string]$Message) Write-Host "[--] $Message (ya instalado/configurado)" -ForegroundColor DarkGray Write-Log "$Message - SKIP" "SKIP" } function Write-Fail { param([string]$Message) Write-Host "[!!] $Message" -ForegroundColor Red Write-Log $Message "ERROR" } function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor Gray Write-Log $Message "INFO" } # --------------------------------------------------------------------------- # Assert-Administrator # --------------------------------------------------------------------------- function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]$identity if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Error "Este script debe ejecutarse como Administrador. Abrelo con 'Ejecutar como administrador'." exit 1 } } # --------------------------------------------------------------------------- # Winget # --------------------------------------------------------------------------- function Test-WingetPackageInstalled { param([string]$Id) # Primera comprobacion mediante exit code winget list --id $Id --exact --accept-source-agreements 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { return $true } # Segunda comprobacion: buscar el ID en la salida (winget a veces devuelve 0 aunque no exista) $output = winget list --id $Id --exact --accept-source-agreements 2>&1 return ($output -match [regex]::Escape($Id)) } function Install-Package { param([pscustomobject]$Pkg) # 0x8A150014 (-1978335212): instalado, reinicio necesario para terminar # 0x8A150013 (-1978335213): reinicio necesario antes de instalar (instalado igualmente) $rebootCodes = @(-1978335212, -1978335213) Write-Info "Comprobando $($Pkg.Name) ($($Pkg.Id))..." try { if (Test-WingetPackageInstalled -Id $Pkg.Id) { Write-Skip $Pkg.Name $script:Summary[$Pkg.Name] = "SKIP" } else { Write-Info "Instalando $($Pkg.Name)..." winget install --id $Pkg.Id --exact --silent --scope machine --source winget --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Success "$($Pkg.Name) instalado" $script:Summary[$Pkg.Name] = "OK" } elseif ($LASTEXITCODE -in $rebootCodes) { Write-Success "$($Pkg.Name) instalado (reinicio pendiente)" $script:Summary[$Pkg.Name] = "OK (reinicio pendiente)" } else { Write-Fail "$($Pkg.Name) - winget termino con codigo $LASTEXITCODE" $script:Summary[$Pkg.Name] = "ERROR ($LASTEXITCODE)" } } } catch { Write-Fail "$($Pkg.Name) - $_" $script:Summary[$Pkg.Name] = "ERROR" } } function Install-AllPackages { Write-Section "Instalacion de paquetes (Winget)" if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Write-Fail "winget no encontrado. Instala 'App Installer' desde Microsoft Store y vuelve a ejecutar el script." $script:Summary["Winget"] = "ERROR - no encontrado" return } foreach ($pkg in $script:Packages) { Install-Package -Pkg $pkg } } # --------------------------------------------------------------------------- # Hosts # --------------------------------------------------------------------------- function Add-HostsEntry { param([pscustomobject]$Entry) $hostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" # (?m) activa modo multilinea: ^ y $ coinciden con inicio/fin de cada linea $pattern = "(?m)^\s*$([regex]::Escape($Entry.Ip))\s+$([regex]::Escape($Entry.Host))\s*$" $content = Get-Content -Path $hostsPath -Raw -Encoding UTF8 if ($content -match $pattern) { Write-Skip "$($Entry.Ip) $($Entry.Host)" $script:Summary["Hosts: $($Entry.Host)"] = "SKIP" } else { try { # Add-Content puede fallar con ArgumentException al detectar la codificacion # del fichero hosts; AppendAllText es mas robusto y no hace deteccion de encoding [System.IO.File]::AppendAllText($hostsPath, "`r`n$($Entry.Ip) $($Entry.Host)", [System.Text.Encoding]::UTF8) Write-Success "Anadida entrada hosts: $($Entry.Ip) $($Entry.Host)" $script:Summary["Hosts: $($Entry.Host)"] = "OK" } catch { Write-Fail "No se pudo escribir en hosts: $_" $script:Summary["Hosts: $($Entry.Host)"] = "ERROR" } } } function Set-HostsEntries { Write-Section "Configuracion del archivo hosts" foreach ($entry in $script:HostsEntries) { Add-HostsEntry -Entry $entry } } # --------------------------------------------------------------------------- # Accesos directos # --------------------------------------------------------------------------- function New-Shortcut { param([pscustomobject]$Sc) $desktopPath = [Environment]::GetFolderPath("CommonDesktopDirectory") $lnkPath = Join-Path $desktopPath "$($Sc.Name).lnk" if (Test-Path $lnkPath) { Write-Skip "Acceso directo '$($Sc.Name)'" $script:Summary["Shortcut: $($Sc.Name)"] = "SKIP" return } if (-not (Test-Path $Sc.Target)) { Write-Info "Destino no encontrado, creando acceso directo igualmente: $($Sc.Target)" } try { $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($lnkPath) $shortcut.TargetPath = $Sc.Target $shortcut.Save() # Liberar el objeto COM antes de tocar el fichero; si no, puede seguir con lock [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shortcut) | Out-Null [System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null [GC]::Collect() [GC]::WaitForPendingFinalizers() if ($Sc.RunAs) { # Activar el flag RunAsUser (bit 13 de LinkFlags = byte 21, mascara 0x20) $bytes = [System.IO.File]::ReadAllBytes($lnkPath) $bytes[21] = $bytes[21] -bor 0x20 [System.IO.File]::WriteAllBytes($lnkPath, $bytes) Write-Success "Acceso directo '$($Sc.Name)' creado (Ejecutar como Admin)" } else { Write-Success "Acceso directo '$($Sc.Name)' creado" } $script:Summary["Shortcut: $($Sc.Name)"] = "OK" } catch { Write-Fail "No se pudo crear acceso directo '$($Sc.Name)': $_" $script:Summary["Shortcut: $($Sc.Name)"] = "ERROR" } } function Set-DesktopShortcuts { Write-Section "Creacion de accesos directos (escritorio comun)" foreach ($sc in $script:Shortcuts) { New-Shortcut -Sc $sc } } # --------------------------------------------------------------------------- # Extraccion de zips (entorno y workspaces) con marcador por tamanio CDN # --------------------------------------------------------------------------- function Get-RemoteSize { param([string]$Url) try { $req = [System.Net.WebRequest]::Create($Url) $req.Method = "HEAD" $resp = $req.GetResponse() $size = $resp.ContentLength $resp.Close() if ($size -gt 0) { return "$size" } } catch {} return "" } function Expand-ZipToDrive { param( [string]$Url, [string]$Dest, [string]$Label ) $fileName = [System.IO.Path]::GetFileName($Url) $markerFile = Join-Path $script:MarkerDir "$fileName.size" $sevenZip = "C:\Program Files\7-Zip\7z.exe" $remoteSize = Get-RemoteSize -Url $Url $storedSize = if (Test-Path $markerFile) { (Get-Content $markerFile -Raw).Trim() } else { "" } if ($remoteSize -and $remoteSize -eq $storedSize) { Write-Skip "$Label (sin cambios en CDN)" $script:Summary[$Label] = "SKIP" return } $tmpZip = Join-Path $env:TEMP "ivap-$fileName" $maxRetries = 3 try { for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue if ($attempt -gt 1) { Write-Info "Reintento $attempt de $maxRetries para $Label..." } else { Write-Info "Descargando $Label..." } $wc = New-Object System.Net.WebClient $wc.DownloadFile($Url, $tmpZip) if (-not (Test-Path $tmpZip)) { throw "El archivo descargado no existe en $tmpZip" } $localSize = (Get-Item $tmpZip).Length if ($localSize -eq 0) { throw "El archivo descargado esta vacio" } # Verificar tamaño contra el remoto if ($remoteSize) { if ("$localSize" -ne $remoteSize) { Write-Fail "Descarga incompleta ($localSize / $remoteSize bytes) — intento $attempt de $maxRetries" if ($attempt -eq $maxRetries) { throw "Descarga incompleta tras $maxRetries intentos: $localSize bytes descargados, se esperaban $remoteSize" } continue } } else { Write-Info "Aviso: no se pudo verificar tamaño remoto, descargados $localSize bytes" } break } if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null } Write-Info "Extrayendo $Label en $Dest..." & $sevenZip x $tmpZip -o"$Dest" -y 2>&1 | Out-Null New-Item -ItemType Directory -Path $script:MarkerDir -Force | Out-Null [System.IO.File]::WriteAllText($markerFile, $remoteSize, [System.Text.Encoding]::UTF8) Write-Success "$Label extraido" $script:Summary[$Label] = "OK" } catch { Write-Fail "Error procesando ${Label}: $_" $script:Summary[$Label] = "ERROR" } finally { Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue } } function Expand-EntornoZip { Write-Section "Extraccion del entorno" try { $response = Invoke-WebRequest -Uri "$script:CdnEntorno/" -UseBasicParsing -ErrorAction Stop $parts = @($response.Links | Where-Object { $_.href -match '\.zip$' } | ForEach-Object { $_.href }) } catch { Write-Fail "No se pudo obtener el listado de zips del CDN: $_" $script:Summary["Entorno (listado CDN)"] = "ERROR" return } if ($parts.Count -eq 0) { Write-Fail "No se encontraron zips en $script:CdnEntorno" $script:Summary["Entorno (listado CDN)"] = "ERROR" return } Write-Info "$($parts.Count) zips encontrados en el CDN" foreach ($part in $parts) { # $part puede ser nombre relativo o URL completa; normalizamos a nombre de fichero $fileName = $part.TrimStart('/').Split('/')[-1] Expand-ZipToDrive -Url "$script:CdnEntorno/$fileName" -Dest "C:\" -Label "Entorno: $fileName" } } # --------------------------------------------------------------------------- # Configuracion Git # --------------------------------------------------------------------------- function Test-GitConfigured { $name = git config --global user.name 2>$null $email = git config --global user.email 2>$null $gpg = git config --global commit.gpgsign 2>$null return ($name -and $email -and $gpg -eq 'true') } function Invoke-GitSetup { Write-Section "Configuracion de Git y GPG" # Refrescar PATH de la sesion actual para detectar git recien instalado $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') if (Test-GitConfigured) { Write-Skip "Git ya configurado (user.name, user.email y commit.gpgsign presentes)" $script:Summary["Configuracion Git/GPG"] = "SKIP" return } Write-Info "Descargando y ejecutando: $script:GitScript" Write-Info "(Este paso es interactivo - sigue las instrucciones en pantalla)" try { $scriptContent = Invoke-RestMethod -Uri $script:GitScript -ErrorAction Stop $tmpFile = Join-Path $env:TEMP "ivap-git-setup.ps1" Set-Content -Path $tmpFile -Value $scriptContent -Encoding UTF8 & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $tmpFile Remove-Item $tmpFile -Force -ErrorAction SilentlyContinue Write-Success "Configuracion Git/GPG completada" $script:Summary["Configuracion Git/GPG"] = "OK" } catch { Write-Fail "Error al ejecutar git.ps1: $_" Write-Info "Puedes ejecutarlo manualmente: irm $script:GitScript | iex" $script:Summary["Configuracion Git/GPG"] = "ERROR" } } # --------------------------------------------------------------------------- # Clonado de repositorios GitLab # --------------------------------------------------------------------------- function Get-PatFromCredentials { # El script corre como admin; el .git-credentials esta en el perfil del usuario # interactivo, no en el del administrador. Se obtiene su perfil via registro. $interactiveUser = (Get-CimInstance -ClassName Win32_ComputerSystem).UserName $username = $interactiveUser.Split('\')[-1] $profilePath = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*" -ErrorAction SilentlyContinue | Where-Object { (Split-Path $_.ProfileImagePath -Leaf) -ieq $username } | Select-Object -First 1).ProfileImagePath if (-not $profilePath) { $profilePath = $env:USERPROFILE } $credFile = Join-Path $profilePath ".git-credentials" if (-not (Test-Path $credFile)) { return $null } $domain = ([Uri]$script:GitLabUrl).Host $line = Get-Content $credFile -Encoding UTF8 | Where-Object { $_ -match [regex]::Escape($domain) } | Select-Object -First 1 if (-not $line) { return $null } # Formato: https://usuario:PAT@hostname if ($line -match 'https://[^:]+:([^@]+)@') { return $Matches[1] } return $null } function Invoke-CloneRepositories { Write-Section "Clonado de repositorios IVAP" $pat = Get-PatFromCredentials if ([string]::IsNullOrWhiteSpace($pat)) { Write-Fail "No se encontro PAT en .git-credentials para $script:GitLabUrl" Write-Info "Ejecuta primero git.ps1 con metodo HTTPS y asegurate de introducir el PAT" $script:Summary["Clonado repositorios"] = "ERROR - PAT no encontrado en .git-credentials" return } Write-Info "PAT obtenido de .git-credentials" # Obtener todos los proyectos del grupo recursivamente via API. # Invoke-RestMethod normaliza %2F -> / en URLs, lo que hace que GitLab devuelva 404 # para grupos anidados (ejie/ivap). Solucion: primero buscar el grupo por nombre para # obtener su ID numerico, y usar ese ID en la segunda llamada (sin problemas de encoding). Write-Info "Consultando proyectos en $script:GitLabGroup..." $headers = @{ "PRIVATE-TOKEN" = $pat } try { $groupName = $script:GitLabGroup.Split('/')[-1] $groups = Invoke-RestMethod -Uri "$script:GitLabUrl/api/v4/groups?search=$groupName&per_page=50" ` -Headers $headers -UseBasicParsing -ErrorAction Stop $targetGroup = $groups | Where-Object { $_.full_path -eq $script:GitLabGroup } | Select-Object -First 1 if (-not $targetGroup) { throw "Grupo '$script:GitLabGroup' no encontrado" } $response = Invoke-RestMethod -Uri "$script:GitLabUrl/api/v4/groups/$($targetGroup.id)/projects?per_page=100&include_subgroups=true" ` -Headers $headers -UseBasicParsing -ErrorAction Stop } catch { Write-Fail "No se pudo consultar la API de GitLab: $_" $script:Summary["Clonado repositorios"] = "ERROR - fallo API" return } if ($response.Count -eq 0) { Write-Fail "No se encontraron proyectos en $script:GitLabGroup" $script:Summary["Clonado repositorios"] = "ERROR - sin proyectos" return } Write-Info "$($response.Count) proyectos encontrados" New-Item -ItemType Directory -Path $script:CloneDestDir -Force | Out-Null $ok = 0; $skip = 0; $err = 0 foreach ($project in $response) { $name = $project.path $target = Join-Path $script:CloneDestDir $name if ($name -eq "documentacion-entorno") { continue } if (Test-Path (Join-Path $target ".git")) { Write-Skip "Repositorio '$name' (ya existe)" $skip++ continue } # Insertar PAT en la URL HTTPS para autenticacion sin prompt $cloneUrl = $project.http_url_to_repo -replace "https://", "https://oauth2:$pat@" Write-Info "Clonando $name..." git clone --quiet $cloneUrl $target 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { Write-Success "$name" $ok++ } else { Write-Fail "$name - error al clonar" $err++ } } $script:Summary["Clonado repositorios"] = "OK ($ok clonados, $skip ya existian, $err errores)" } # --------------------------------------------------------------------------- # Resumen final # --------------------------------------------------------------------------- function Show-Summary { Write-Section "Resumen de la instalacion" foreach ($key in $script:Summary.Keys) { $status = $script:Summary[$key] switch -Wildcard ($status) { "OK" { Write-Host " [OK] $key" -ForegroundColor Green } "SKIP" { Write-Host " [--] $key" -ForegroundColor DarkGray } "ERROR*" { Write-Host " [!!] $key - $status" -ForegroundColor Red } default { Write-Host " [??] $key - $status" -ForegroundColor Yellow } } } Write-Host "" Write-Host "Log completo: $script:LogFile" -ForegroundColor Cyan Write-Host "" } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- function Main { Write-Log "========================================" "START" Write-Log "Inicio de ivap-setup.ps1" "START" Write-Log "Usuario: $env:USERNAME Equipo: $env:COMPUTERNAME" "START" Write-Host "" Write-Host "╔══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ IVAP - Setup del entorno de desarrollo ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan Assert-Administrator Install-AllPackages Expand-EntornoZip Set-HostsEntries Set-DesktopShortcuts Invoke-GitSetup Invoke-CloneRepositories Show-Summary Write-Log "Fin de ivap-setup.ps1" "END" } Main