<# .SYNOPSIS WUIC one-line installer for Windows (Windows 10/11 and Windows Server 2019+). Hosted on https://wuic-framework.com/install.ps1 - the Windows counterpart of install.sh. Run it from a regular PowerShell window (Windows PowerShell 5.1 or PowerShell 7 both work): irm https://wuic-framework.com/install.ps1 | iex With options: & ([scriptblock]::Create((irm https://wuic-framework.com/install.ps1))) -WithTutorial -Port 5000 .DESCRIPTION What it does, in order: 1. Checks winget (ships with Windows 10 22H2+ / 11 / Server 2022+). 2. Installs the ASP.NET Core Runtime 10 if `dotnet --list-runtimes` does not list Microsoft.AspNetCore.App 10.x (winget id Microsoft.DotNet.AspNetCore.10). 3. Looks for a SQL Server instance reachable with Windows authentication (localhost, localhost\SQLEXPRESS, (localdb)\MSSQLLocalDB, or -SqlServer). If none answers it installs SQL Server 2022 Express via winget (id Microsoft.SQLServer.2022.Express, instance SQLEXPRESS), unless -NoSqlInstall. 4. Reads https://wuic-framework.com/downloads/releases.json, picks the latest IIS-ready package (RAG engine included; with -WithTutorial the variant that ships the WideWorldImporters demo database as .bak) and downloads it. 5. Extracts it under -InstallDir (default %LOCALAPPDATA%\WUIC\app), writes start-wuic.cmd, starts the backend on Kestrel (http://localhost:) and opens the browser on the first-run wizard, where you paste the connection string printed at the end and choose the admin password. By default the app is published in IIS: the script enables the IIS role if needed, installs the ASP.NET Core Hosting Bundle, creates the application pool and the site "WUIC" on the chosen port, and grants the pool identity access to the install folder and to SQL Server. IIS needs administrator rights, so the script asks for elevation (UAC) once. With -Kestrel nothing is registered in IIS: the app runs in a console window you can close, and start-wuic.cmd restarts it. Use it when you cannot or do not want to enable IIS (no administrator rights, IIS feature disabled by policy, or a throwaway evaluation). With -Src the script sets up a DEVELOPER workstation instead of a server: it checks the .NET SDK 10 and Node.js 22 (installing what is missing winget when missing), makes sure a SQL Server answers, downloads the sources package (WuicTest-src-*.zip: C# host project + Angular app, framework from NuGet/npm), extracts it into -InstallDir (default C:\dev\WuicTest), runs `dotnet restore` and `npm install`, installs the WUIC Assistant extension in VS Code when `code` is on the PATH, and prints how to open the workspace. Nothing is started and nothing is registered in IIS. & ([scriptblock]::Create((irm https://wuic-framework.com/install.ps1))) -Src .PARAMETER InstallDir Root folder of the installation. The app goes in \app, the downloaded zip in \downloads. Default: %LOCALAPPDATA%\WUIC. .PARAMETER Port HTTP port of the backend on localhost. Default 5000. .PARAMETER WithTutorial Download the package that ships the WideWorldImporters tutorial database (~1.2 GB instead of ~640 MB). The first-run wizard can then provision the demo data with one click ("Tutorial WideWorldImporters" setup mode). The .bak variant is a SQL Server 2022 backup and needs SQL Server 2022 or newer; on SQL Server 2019 the SQL-script variant is chosen instead (same data, slower first-run restore). SQL Server 2019 is the minimum for the packages without .bak. .PARAMETER SqlServer SQL Server data source to use (e.g. "localhost\SQLEXPRESS" or "MYHOST,1433"). Default: auto-detect with Windows authentication. .PARAMETER NoSqlInstall Never install SQL Server Express: fail if no instance is reachable. .PARAMETER Kestrel Do not touch IIS: run the app on Kestrel in a console window (start-wuic.cmd). No administrator rights needed. .PARAMETER Src Developer setup: sources package, dev toolchain, `dotnet restore` + `npm install`, VS Code extension. Default -InstallDir C:\dev\WuicTest. No IIS, nothing started. .PARAMETER NoRag With -Src, take the smaller sources package without the RAG engine models (~170 MB instead of ~570 MB). The in-app chatbot and WUIC Assistant need the RAG. .PARAMETER NoLockDownload With -Src, do not fetch the published package-lock.json when the extracted package does not carry one. Without a lock, npm 10.9.x (the version Node 22 LTS ships) fails to resolve this dependency graph and the install falls back to --legacy-peer-deps. .PARAMETER NoStart Download and extract only: do not start the backend nor open the browser. .PARAMETER ListenAll Bind Kestrel to all interfaces (http://0.0.0.0:) instead of localhost only, so other machines on the network can reach the app. Windows Firewall must allow inbound TCP for dotnet.exe (the first start prompts for it on an interactive desktop). .PARAMETER Version Release key to install (e.g. "v1.7.0_1.7.0"). Default: the `latest` entry of releases.json. .PARAMETER PackageUrl Installa un pacchetto scelto da chi lancia invece di quello risolto da downloads/releases.json. Accetta un URL http(s), un percorso locale o un file:// URL. Serve a installare una build non ancora pubblicata - l'equivalente di --tarball-url dell'installer Linux. Esempio: .\install.ps1 -Src -PackageUrl 'C:/build/WuicTest-src-1.7.1.zip' .PARAMETER BaseUrl Download origin. Default https://wuic-framework.com. .NOTES Compatible with Windows PowerShell 5.1: no `??`, `?.` or ternaries on purpose. Exit codes: 0 ok, 1 prerequisite missing, 2 download/extract failure, 3 backend did not answer in time, 4 IIS setup failure, 5 restore/npm install failure. #> [CmdletBinding()] param( [string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'WUIC'), [int]$Port = 5000, [switch]$WithTutorial, # Motore di database. L'installer Linux ha --dbms da sempre; qui mancava, e chi voleva # MySQL doveva installarlo a mano e indovinare la stringa di connessione. [ValidateSet('mssql', 'mysql', 'postgres', 'oracle')] [string]$Dbms = 'mssql', [string]$SqlServer = '', [switch]$NoSqlInstall, [string]$MySqlHost = 'localhost', [int]$MySqlPort = 3306, [string]$MySqlUser = 'root', [string]$MySqlPassword = '', [string]$PostgresHost = 'localhost', [int]$PostgresPort = 5432, [string]$PostgresUser = 'postgres', [string]$PostgresPassword = '', # Oracle su Windows non ha un installer non presidiato (winget pubblica solo client e # SQLcl): l'istanza deve esistere, e questi parametri servono a raggiungerla. [string]$OracleHost = 'localhost', [int]$OraclePort = 1521, [string]$OracleService = 'XEPDB1', [string]$OracleUser = 'system', [string]$OraclePassword = '', [switch]$NoStart, [switch]$ListenAll, [switch]$Kestrel, [switch]$Src, [switch]$NoRag, [switch]$NoLockDownload, [switch]$Elevated, [string]$Version = 'latest', [string]$BaseUrl = 'https://wuic-framework.com', # Pacchetto alternativo, al posto di quello risolto da downloads/releases.json. # Accetta un URL http(s) o un percorso locale (anche file://). Serve a installare una # build non ancora pubblicata: e' l'equivalente di --tarball-url dell'installer Linux, # che qui mancava, quindi provare una correzione voleva dire modificare l'installer # invece di dargli un artefatto diverso. [string]$PackageUrl = '' ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } function Write-Step([string]$msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan } function Write-Ok([string]$msg) { Write-Host " [ok] $msg" -ForegroundColor Green } function Write-Info([string]$msg) { Write-Host " $msg" -ForegroundColor Gray } function Write-Warn2([string]$msg){ Write-Host " [!] $msg" -ForegroundColor Yellow } function Fail([string]$msg, [int]$code) { Write-Host ""; Write-Host "ERROR: $msg" -ForegroundColor Red; exit $code } function Refresh-Path { # winget installers update the Machine/User PATH; the running session keeps the # old copy. Merge the registry values so `dotnet` resolves without a new shell. $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') $user = [Environment]::GetEnvironmentVariable('Path', 'User') $env:Path = ($machine, $user, $env:Path) -join ';' } function Test-Winget { return [bool](Get-Command winget -ErrorAction SilentlyContinue) } function Resolve-WingetVersionedId([string]$prefix, [string]$fallback) { # winget non pubblica sempre un id senza versione. PostgreSQL, per esempio, esiste solo # come PostgreSQL.PostgreSQL.: chiedere 'PostgreSQL.PostgreSQL' fa uscire winget # con -1978335212 ("No package found matching input criteria") e l'installazione si # ferma li' (verificato 2026-09-08). Si cerca quindi la major piu' alta disponibile # invece di inchiodarne una, che invecchierebbe a ogni rilascio. # Locale alla funzione: con 'Stop' (impostato in cima allo script) PowerShell 5.1 puo' # trasformare in eccezione qualunque riga che winget scriva su stderr, e qui una ricerca # senza risultati e' un esito normale, non un errore. $ErrorActionPreference = 'Continue' try { $out = & winget search --id $prefix --source winget --accept-source-agreements 2>&1 | Out-String } catch { return $fallback } $maggiore = -1 foreach ($m in [regex]::Matches($out, [regex]::Escape($prefix) + '\.(\d+)')) { $v = [int]$m.Groups[1].Value if ($v -gt $maggiore) { $maggiore = $v } } if ($maggiore -lt 0) { return $fallback } return "${prefix}.${maggiore}" } function Resolve-SqlExpressId { # L'anno di SQL Server Express NON si inchioda: Microsoft ritira i bootstrapper delle # edizioni vecchie, e quando succede winget scarica un eseguibile che si rifiuta di # partire mostrando "This version of the installer is no longer supported" in una finestra # modale. L'installazione non presidiata resta appesa li' per sempre: verificato il # 2026-09-09 sulla VM di prova con Microsoft.SQLServer.2022.Express (16.0.1000.6), che il # 2026-09-08 funzionava ancora. Stessa lezione gia' imparata su Linux con i repository # mssql-server-2022 / 2025. # Si prende quindi l'anno piu' alto che winget pubblica come <...>.Express. $ErrorActionPreference = 'Continue' try { $out = & winget search --id Microsoft.SQLServer --source winget --accept-source-agreements 2>&1 | Out-String } catch { return 'Microsoft.SQLServer.2022.Express' } $anno = -1 foreach ($m in [regex]::Matches($out, 'Microsoft\.SQLServer\.(\d{4})\.Express')) { $v = [int]$m.Groups[1].Value if ($v -gt $anno) { $anno = $v } } if ($anno -lt 0) { return 'Microsoft.SQLServer.2022.Express' } return "Microsoft.SQLServer.${anno}.Express" } function Invoke-Winget([string]$id) { # 1618 (ERROR_INSTALL_ALREADY_RUNNING, che winget riporta come -1978334974) NON e' un # fallimento dell'installazione: e' il mutex MSI di Windows occupato da un altro installer # — tipicamente Windows Update o un pacchetto che sta finendo dopo il boot. Si libera da # solo in qualche decina di secondi. Fermarsi li' e dire "installalo a mano" fa fallire # un'installazione che sarebbe riuscita aspettando: il 19/09/2026 e' costato un giro intero # della journey su MySQL (`winget install Oracle.MySQL` -> exit 1618 a 46 secondi dal boot, # e il tentativo successivo, a freddo, e' passato senza toccare niente). # Tre tentativi con attesa crescente (30 s, 60 s): coprono la finestra tipica senza # nascondere un errore vero, che dopo il terzo giro viene riportato come prima. for ($tentativo = 1; $tentativo -le 3; $tentativo++) { $esito = Invoke-WingetOnce $id if ($esito -eq 0) { return } if ($tentativo -eq 3) { Fail "winget install $id failed with exit code $esito. Install it manually and rerun." 1 } $attesa = 30 * $tentativo Write-Warn2 "winget install ${id}: un'altra installazione e' in corso su questa macchina (exit $esito). Riprovo fra $attesa s (tentativo $($tentativo + 1) di 3)." Start-Sleep -Seconds $attesa } } # Ritorna 0 se il pacchetto e' installato (o gia' presente), altrimenti il codice di uscita. # Il codice torna al chiamante invece di far fallire subito: e' Invoke-Winget a decidere se # quel codice merita un altro tentativo. function Invoke-WingetOnce([string]$id) { Write-Info "winget install --id $id (a UAC prompt may appear)" # `--silent` vuol dire che winget non stampa niente per minuti: senza un battito qui # l'installazione di SQL Server Express e' una finestra ferma sull'output della fase # precedente, e chi guarda non sa se stia lavorando o se sia piantata. Il tempo lo diciamo # noi, ogni dieci secondi. $t0 = Get-Date $p = Start-Process -FilePath 'winget' -ArgumentList @('install', '--id', $id, '--exact', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') -PassThru -NoNewWindow # Toccare .Handle NON e' decorativo: con Start-Process -PassThru senza -Wait, .NET non # conserva il codice di uscita e $p.ExitCode torna $null a processo finito. Il 13/09 questo # ha fatto fallire un'installazione riuscita con "failed with exit code ." (codice vuoto). $null = $p.Handle while (-not $p.HasExited) { Start-Sleep -Seconds 10 if ($p.HasExited) { break } $mm = '{0:mm\:ss}' -f ([timespan]::FromSeconds([int]((Get-Date) - $t0).TotalSeconds)) Write-Host " ... ${id}: installazione in corso da $mm (winget lavora in silenzio, e' normale)" -ForegroundColor DarkCyan } $secs = [int]((Get-Date) - $t0).TotalSeconds # 0 = installed, -1978335189 (0x8A15002B) = already installed / no applicable upgrade if ($p.ExitCode -ne 0 -and $p.ExitCode -ne -1978335189) { return $p.ExitCode } Write-Ok "$id pronto in $secs s" Refresh-Path return 0 } function Test-AspNetRuntime10 { $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if (-not $dotnet) { return $false } $list = & dotnet --list-runtimes 2>$null return [bool]($list | Where-Object { $_ -match '^Microsoft\.AspNetCore\.App 10\.' }) } function Test-DotnetSdk10 { try { $sdks = & dotnet --list-sdks 2>$null; return [bool]($sdks | Where-Object { $_ -match '^10\.' }) } catch { return $false } } function Get-NodeMajor { try { $v = (& node --version 2>$null); if ($v -match '^v(\d+)') { return [int]$Matches[1] } } catch { } return 0 } # Un installer che tace non e' un installer silenzioso: e' un installer che sembra piantato. # `npm install` e il setup di SQL Server Express passano minuti interi senza stampare una riga, # e a schermo resta l'ultimo messaggio della fase PRECEDENTE - quindi si legge "Success" mentre # in realta' sta ancora lavorando a tutt'altro. Questa funzione fa da battito: mostra le righe # del comando appena arrivano e, quando non ne arrivano, dice ogni 10 secondi da quanto sta # lavorando e a cosa. function Invoke-ConBattito([string]$label, [string]$exe, [string[]]$exeArgs, [string]$cwd) { $outFile = [System.IO.Path]::GetTempFileName() $errFile = [System.IO.Path]::GetTempFileName() $t0 = Get-Date # Start-Process unisce la lista argomenti con degli spazi e NON quota: un argomento che # contiene uno spazio arriva spezzato in due, mentre `& $exe @exeArgs` lo passava intero. # Oggi nessuna delle due chiamate dell installer passa argomenti con spazi, quindi non si # vede; resta una mina per la prossima. Si quota qui, una volta. $argQuotati = @($exeArgs | ForEach-Object { if ($_ -match '\s' -and -not $_.StartsWith('"')) { '"' + $_ + '"' } else { $_ } }) $p = Start-Process -FilePath $exe -ArgumentList $argQuotati -WorkingDirectory $cwd -NoNewWindow -PassThru ` -RedirectStandardOutput $outFile -RedirectStandardError $errFile $null = $p.Handle # vedi Invoke-Winget: senza questo $p.ExitCode puo' tornare $null $letteOut = 0; $letteErr = 0 $ultimaStampa = Get-Date $ultimaRiga = '' while (-not $p.HasExited) { Start-Sleep -Milliseconds 500 foreach ($f in @($outFile, $errFile)) { $lette = if ($f -eq $outFile) { $letteOut } else { $letteErr } try { $righe = @(Get-Content -LiteralPath $f -ErrorAction SilentlyContinue) } catch { $righe = @() } if ($righe.Count -gt $lette) { for ($i = $lette; $i -lt $righe.Count; $i++) { if ($righe[$i] -ne $null -and $righe[$i].Trim() -ne '') { Write-Host " $($righe[$i])" -ForegroundColor DarkGray $ultimaRiga = $righe[$i]; $ultimaStampa = Get-Date } } if ($f -eq $outFile) { $letteOut = $righe.Count } else { $letteErr = $righe.Count } } } if (((Get-Date) - $ultimaStampa).TotalSeconds -ge 10) { $sp = [int]((Get-Date) - $t0).TotalSeconds $mm = '{0:mm\:ss}' -f ([timespan]::FromSeconds($sp)) $coda = if ($ultimaRiga) { " - ultima: $($ultimaRiga.Trim().Substring(0, [Math]::Min(60, $ultimaRiga.Trim().Length)))" } else { '' } Write-Host " ... $label in corso da $mm$coda" -ForegroundColor DarkCyan $ultimaStampa = Get-Date } } foreach ($f in @($outFile, $errFile)) { $lette = if ($f -eq $outFile) { $letteOut } else { $letteErr } try { $righe = @(Get-Content -LiteralPath $f -ErrorAction SilentlyContinue) } catch { $righe = @() } for ($i = $lette; $i -lt $righe.Count; $i++) { if ($righe[$i] -ne $null -and $righe[$i].Trim() -ne '') { Write-Host " $($righe[$i])" -ForegroundColor DarkGray } } } Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue return $p.ExitCode } function Invoke-Timed([string]$label, [string]$exe, [string[]]$exeArgs, [string]$cwd, [string[]]$retryArgs, [string]$retryWhy) { Write-Step "$label ($exe $($exeArgs -join ' '))" $t0 = Get-Date Push-Location $cwd # Native commands write progress and warnings on stderr; with $ErrorActionPreference='Stop' # PowerShell turns those into terminating errors, so the exit code is never even read. $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $code = Invoke-ConBattito $label $exe $exeArgs $cwd } finally { $ErrorActionPreference = $prev; Pop-Location } $secs = [int]((Get-Date) - $t0).TotalSeconds if ($code -ne 0 -and $retryArgs) { Write-Warn2 "$label failed (exit $code after $secs s). $retryWhy" Write-Step "$label - retry ($exe $($retryArgs -join ' '))" $t1 = Get-Date Push-Location $cwd $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $code = Invoke-ConBattito "$label - retry" $exe $retryArgs $cwd } finally { $ErrorActionPreference = $prev; Pop-Location } $secs = [int]((Get-Date) - $t1).TotalSeconds } if ($code -ne 0) { Fail "$label failed (exit $code after $secs s) in $cwd" 5 } Write-Ok "$label done in $secs s" } function Test-SqlDataSource([string]$ds) { $cs = "Data Source=$ds;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True;Connect Timeout=6" try { $conn = New-Object System.Data.SqlClient.SqlConnection($cs) $conn.Open() $cmd = $conn.CreateCommand(); $cmd.CommandText = "SELECT SERVERPROPERTY('ProductVersion')" $ver = [string]$cmd.ExecuteScalar() $conn.Close() $major = 0; if ($ver -match '^(\d+)\.') { $major = [int]$Matches[1] } if ($major -gt 0 -and $major -lt 15) { Write-Warn2 "$ds is SQL Server ${ver}: WUIC needs 2019+ (version 15; 2022+ for the .bak tutorial). Skipping it."; return $false } Write-Ok "SQL Server reachable at $ds (version $ver)" $script:SqlMajorVersion = $major return $true } catch { return $false } } function Test-TcpPort([string]$targetHost, [int]$port, [int]$timeoutMs = 6000) { # WaitOne dice solo che l'operazione asincrona E' TERMINATA, non che sia riuscita: una # connessione rifiutata la fa tornare $true. Serve EndConnect, che solleva l'eccezione # reale, altrimenti si dichiara raggiungibile un motore che non c'e'. $tcp = New-Object System.Net.Sockets.TcpClient try { $async = $tcp.BeginConnect($targetHost, $port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($timeoutMs)) { return $false } $tcp.EndConnect($async) return $tcp.Connected } catch { return $false } finally { $tcp.Close() } } function Test-PostgresDataSource([string]$pgHost, [int]$port, [string]$user, [string]$password) { # Come sopra: psql scrive avvisi e messaggi di errore su stderr, e sotto 'Stop' # fermerebbero l'installazione invece di essere un esito da leggere. $ErrorActionPreference = 'Continue' if (-not (Test-TcpPort $pgHost $port)) { return $false } # Il client non e' nel PATH della sessione subito dopo l'installazione con winget: si # cerca dove PostgreSQL si installa. Senza questa ricerca la funzione tornava VERA # avendo verificato solo che la porta risponde - e una porta aperta non e' un database # su cui ci si puo' autenticare. Il 2026-09-08 l'installazione proseguiva "riuscita" e il # wizard falliva dopo, con un timeout che sembrava un difetto del wizard. $psqlPath = Find-DbClient 'C:/Program Files/PostgreSQL' 'psql.exe' $psql = if ($psqlPath) { [pscustomobject]@{ Source = $psqlPath } } else { $null } if (-not $psql) { Write-Warn2 "PostgreSQL: ${pgHost}:${port} answers but there is no psql client to verify the login, so it is not taken for granted: pass -PostgresUser/-PostgresPassword of an instance you already configured." return $false } $env:PGPASSWORD = $password $ver = & $psql.Source -w -h $pgHost -p $port -U $user -tAc 'SHOW server_version' 2>&1 Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue if ($LASTEXITCODE -ne 0) { return $false } Write-Ok "PostgreSQL reachable at ${pgHost}:${port} (version $(([string]$ver).Trim()))" return $true } function Test-OracleDataSource([string]$oraHost, [int]$port) { # Nessun client Oracle garantito su una macchina pulita: ci si ferma alla porta del # listener. Se risponde, la stringa di connessione la verifica il wizard. if (-not (Test-TcpPort $oraHost $port)) { return $false } Write-Ok "Un listener Oracle risponde su ${oraHost}:${port}" return $true } # I segreti generati dall'installer (password di root del motore appena installato) vanno # scritti da qualche parte, altrimenti l'utente resta con un database che non sa aprire. # L'equivalente Linux e' /etc/wuiccore/secrets.env; qui il file sta nella cartella di # installazione, leggibile solo da Administrators e dal proprietario. $script:SecretsPath = Join-Path $InstallDir 'wuic-secrets.json' # I percorsi dei client si cercano UNA volta sola: la ricerca ricorsiva sotto # "C:\Program Files\" attraversa anche la cartella dati, che ha migliaia di file, e # le sonde di prontezza girano in un ciclo ogni 10 secondi per cinque minuti. Senza questa # memoria la verifica costava piu' dell'installazione che doveva verificare. $script:ClientCache = @{} function Find-DbClient([string]$radice, [string]$eseguibile) { $chiave = "$radice|$eseguibile" if ($script:ClientCache.ContainsKey($chiave)) { return $script:ClientCache[$chiave] } $cmd = Get-Command $eseguibile -ErrorAction SilentlyContinue $percorso = if ($cmd) { $cmd.Source } else { $f = Get-ChildItem $radice -Recurse -Filter $eseguibile -ErrorAction SilentlyContinue | Select-Object -First 1 if ($f) { $f.FullName } else { '' } } $script:ClientCache[$chiave] = $percorso return $percorso } function Get-RandomPassword { # Niente apostrofi, virgolette, backslash o punti e virgola: la password finisce dentro # una stringa di connessione e dentro una istruzione SQL, e i caratteri che le spezzano # non valgono l'entropia che aggiungono. $alfabeto = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' # Generatore CRITTOGRAFICO, non System.Random: questa e' la password di root di un # database, e System.Random e' seminato dall'orologio, quindi due installazioni avviate # nello stesso istante possono produrre la stessa password. $rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider $byte = New-Object byte[] 1 $sb = New-Object System.Text.StringBuilder for ($i = 0; $i -lt 24; $i++) { # Scarto i valori nella coda non divisibile: il modulo puro favorirebbe i primi # caratteri dell'alfabeto. do { $rng.GetBytes($byte) } while ($byte[0] -ge (256 - (256 % $alfabeto.Length))) [void]$sb.Append($alfabeto[$byte[0] % $alfabeto.Length]) } $rng.Dispose() # Complessita' richiesta dalle policy dei motori: una maiuscola, una minuscola, una cifra # e un simbolo innocuo ci sono comunque. return $sb.ToString() + 'Aa1-' } function Save-DbSecret([string]$nome, [string]$valore) { $dati = @{} if (Test-Path $script:SecretsPath) { try { (Get-Content $script:SecretsPath -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $dati[$_.Name] = $_.Value } } catch { } } $dati[$nome] = $valore $dir = Split-Path $script:SecretsPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # UTF-8 senza BOM: Set-Content -Encoding UTF8 su PS 5.1 scrive il BOM e i parser JSON # rigorosi lo rifiutano. [System.IO.File]::WriteAllText($script:SecretsPath, (($dati | ConvertTo-Json)), (New-Object System.Text.UTF8Encoding($false))) try { # Solo Administrators e SYSTEM: il file contiene password in chiaro, come l'equivalente Linux. & icacls $script:SecretsPath /inheritance:r /grant 'BUILTIN\Administrators:F' 'NT AUTHORITY\SYSTEM:F' | Out-Null } catch { Write-Warn2 "Could not restrict the permissions of $($script:SecretsPath): $($_.Exception.Message)" } } function Get-DbSecret([string]$nome) { # Contraltare di Save-DbSecret. La password del motore la genera questo installer e la # scrive li' dentro: alla RI-esecuzione dell'one-liner sulla stessa macchina deve poterla # rileggere, altrimenti sonda con la password vuota, si vede rifiutare l'autenticazione e # conclude che il database "non risponde" mentre risponde benissimo (verificato il # 2026-09-10 sulla journey win-iis/mysql: ERROR 1045 letto come porta chiusa). if (-not (Test-Path $script:SecretsPath)) { return '' } try { $prop = (Get-Content $script:SecretsPath -Raw | ConvertFrom-Json).PSObject.Properties[$nome] if ($prop) { return [string]$prop.Value } } catch { } return '' } function Test-MySqlDataSource([string]$mysqlHost, [int]$port, [string]$user, [string]$password) { # Locale: mysql.exe avverte su stderr quando la password sta sulla riga di comando, e # con 'Stop' quell'avviso diventa un'eccezione terminante. La sonda deve GIUDICARE il # risultato, non morire sul rumore (stessa trappola vista due volte il 2026-09-08). $ErrorActionPreference = 'Continue' # Niente client MySQL in .NET Framework: si prova la porta e, se c'e' mysql.exe, una query. if (-not (Test-TcpPort $mysqlHost $port)) { return $false } # Come per PostgreSQL: il client non e' nel PATH della sessione subito dopo winget, e # dichiarare "risponde" avendo solo visto la porta aperta significa dare per buono un # database su cui non ci si e' mai autenticati. Si cerca dove MySQL si installa. $mysqlPath = Find-DbClient 'C:/Program Files/MySQL' 'mysql.exe' $mysqlExe = if ($mysqlPath) { [pscustomobject]@{ Source = $mysqlPath } } else { $null } if (-not $mysqlExe) { Write-Warn2 "MySQL: ${mysqlHost}:${port} answers but there is no mysql.exe client to verify the login, so it is not taken for granted." return $false } $args = @('-h', $mysqlHost, '-P', "$port", '-u', $user, '--connect-timeout=6', '-N', '-B', '-e', 'SELECT VERSION()') if ($password) { $args += "--password=$password" } $out = & $mysqlExe.Source @args 2>&1 if ($LASTEXITCODE -ne 0) { return $false } # Quando la password c'e', mysql.exe scrive su stderr "Using a password on the command # line interface can be insecure", e con 2>&1 quell'avviso finisce dentro $out. La # versione va quindi CERCATA fra le righe: prendendo il blocco intero, $ver diventava # "mysql: [Warning] ... 8.4.9", il match ^(\d+)\. falliva, $major restava 0 e il # controllo ">= 8.0" qui sotto non veniva MAI eseguito (verificato 2026-09-10). $ver = @($out) | ForEach-Object { ([string]$_).Trim() } | Where-Object { $_ -match '^\d+\.\d+' } | Select-Object -First 1 if (-not $ver) { $ver = ([string]$out).Trim() } $major = 0; if ($ver -match '^(\d+)\.') { $major = [int]$Matches[1] } if ($major -gt 0 -and $major -lt 8) { Write-Warn2 "MySQL ${ver}: WUIC needs 8.0 or later."; return $false } Write-Ok "MySQL reachable at ${mysqlHost}:${port} (version $ver)" return $true } function Find-SqlServer { if ($SqlServer) { if (Test-SqlDataSource $SqlServer) { return $SqlServer } Fail "SQL Server '$SqlServer' is not reachable with Windows authentication." 1 } foreach ($cand in @('localhost', "localhost\SQLEXPRESS", "(localdb)\MSSQLLocalDB")) { if (Test-SqlDataSource $cand) { return $cand } } return $null } function Get-RemoteLength([string]$url) { try { $req = [System.Net.WebRequest]::Create($url); $req.Method = 'HEAD'; $req.Timeout = 20000 $res = $req.GetResponse(); $len = $res.ContentLength; $res.Close(); return $len } catch { return -1 } } function Download-File([string]$url, [string]$dest) { # Un pacchetto locale (percorso o file://) si copia: BITS e WebClient su file:// hanno # comportamenti diversi fra le versioni di Windows e non vale la pena dipenderci. $localPath = '' if ($url -like 'file://*') { $localPath = ([Uri]$url).LocalPath } elseif ($url -notmatch '^[a-zA-Z]+://') { $localPath = $url } if ($localPath) { if (-not (Test-Path $localPath)) { Fail "Local package not found: $localPath" 2 } Write-Info "Local package: $localPath -> $dest" Copy-Item -LiteralPath $localPath -Destination $dest -Force Write-Ok "Copied $([math]::Round((Get-Item $dest).Length / 1MB)) MB" return } $expected = Get-RemoteLength $url if ((Test-Path $dest) -and $expected -gt 0 -and (Get-Item $dest).Length -eq $expected) { Write-Ok "Already downloaded: $dest" return } if (Test-Path $dest) { Remove-Item $dest -Force } $sizeMb = if ($expected -gt 0) { [math]::Round($expected / 1MB) } else { '?' } Write-Info "Downloading $url ($sizeMb MB) -> $dest" $bits = Get-Command Start-BitsTransfer -ErrorAction SilentlyContinue if ($bits) { try { Start-BitsTransfer -Source $url -Destination $dest -DisplayName 'WUIC package' -Description $url; } catch { Write-Warn2 "BITS failed ($($_.Exception.Message)); falling back to WebClient." ; if (Test-Path $dest) { Remove-Item $dest -Force } } } if (-not (Test-Path $dest)) { $wc = New-Object System.Net.WebClient $wc.DownloadFile($url, $dest) } $actual = (Get-Item $dest).Length if ($expected -gt 0 -and $actual -ne $expected) { Fail "Download incomplete: $actual of $expected bytes. Rerun to resume." 2 } Write-Ok "Downloaded $([math]::Round($actual / 1MB)) MB" } function Expand-Package([string]$zip, [string]$target, [string]$marker = 'WuicTest.dll') { Add-Type -AssemblyName System.IO.Compression.FileSystem if (Test-Path $target) { Remove-Item $target -Recurse -Force } New-Item -ItemType Directory -Path $target -Force | Out-Null Write-Info "Extracting to $target (a few minutes for a 600 MB archive)" [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $target) # The archive may carry a single top-level folder: flatten it so the marker file sits in $target. # The marker differs per audience: WuicTest.dll for the IIS build, WuicTest.csproj for the sources. if (-not (Test-Path (Join-Path $target $marker))) { $dirs = @(Get-ChildItem -Path $target -Directory) $files = @(Get-ChildItem -Path $target -File) if ($dirs.Count -eq 1 -and $files.Count -eq 0 -and (Test-Path (Join-Path $dirs[0].FullName $marker))) { $inner = $dirs[0].FullName Get-ChildItem -Path $inner -Force | Move-Item -Destination $target -Force Remove-Item $inner -Recurse -Force } } if (-not (Test-Path (Join-Path $target $marker))) { Fail "$marker not found after extraction: unexpected package layout." 2 } Write-Ok "Extracted" } function Test-IsAdmin { $id = [Security.Principal.WindowsIdentity]::GetCurrent() return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # IIS needs an elevated session. The one-liner is pasted in a normal shell, so # re-download this script to a temp file and relaunch it through UAC with the # same arguments; -Elevated stops the child from doing it again. function Invoke-SelfElevate { $tmp = Join-Path $env:TEMP ("wuic-install-" + [Guid]::NewGuid().ToString('N') + ".ps1") try { Invoke-WebRequest -Uri "$BaseUrl/install.ps1" -UseBasicParsing -OutFile $tmp -TimeoutSec 60 } catch { Fail "Cannot re-download the installer for elevation ($BaseUrl/install.ps1): $($_.Exception.Message). Run PowerShell as administrator and paste the command again, or add -Kestrel." 1 } $a = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $tmp, '-InstallDir', $InstallDir, '-Port', $Port, '-Version', $Version, '-BaseUrl', $BaseUrl, '-Elevated') if ($WithTutorial) { $a += '-WithTutorial' } if ($NoStart) { $a += '-NoStart' } if ($ListenAll) { $a += '-ListenAll' } if ($NoSqlInstall) { $a += '-NoSqlInstall' } if ($SqlServer) { $a += @('-SqlServer', $SqlServer) } Write-Info "IIS setup needs administrator rights: accept the UAC prompt (or rerun with -Kestrel to stay on Kestrel)." try { $proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $a -Verb RunAs -PassThru -Wait } catch { Fail "Elevation was refused. Rerun from an elevated PowerShell, or add -Kestrel to run on Kestrel without IIS." 1 } Remove-Item $tmp -Force -ErrorAction SilentlyContinue exit $proc.ExitCode } function Get-AppCmd { return (Join-Path $env:WINDIR 'system32\inetsrv\appcmd.exe') } function Test-IisPresent { return (Test-Path (Get-AppCmd)) } function Enable-IisFeatures { # Client SKUs (Windows 10/11, Home included) and Server both expose IIS as an # optional feature; the management console is what appcmd needs. $features = @('IIS-WebServerRole', 'IIS-WebServer', 'IIS-ManagementConsole', 'IIS-ManagementScriptingTools') foreach ($f in $features) { try { $st = Get-WindowsOptionalFeature -Online -FeatureName $f -ErrorAction Stop if ($st.State -ne 'Enabled') { Write-Info "enabling Windows feature $f" Enable-WindowsOptionalFeature -Online -FeatureName $f -All -NoRestart -ErrorAction Stop | Out-Null } } catch { Write-Warn2 "cannot enable $f automatically: $($_.Exception.Message)" } } } function Test-HostingBundle { $dll = Join-Path $env:ProgramFiles 'IIS\Asp.Net Core Module\V2\aspnetcorev2.dll' return (Test-Path $dll) } function Invoke-AppCmd([string[]]$cmdArgs) { # appcmd scrive gli errori su stderr e li segnala anche con l'exit code, che e' quello # che questa funzione restituisce a chi la chiama. Con $ErrorActionPreference = 'Stop' # pero' la prima riga su stderr diventa un'eccezione terminante e il controllo # sull'exit code non viene mai raggiunto: la funzione non puo' fare cio' per cui esiste. # Stessa famiglia del guasto di mysql.exe del 2026-09-08. $ErrorActionPreference = 'Continue' $out = & (Get-AppCmd) @cmdArgs 2>&1 return @{ code = $LASTEXITCODE; out = ($out | Out-String) } } function Grant-FolderAccess([string]$account, [string]$folder) { # Come sopra: icacls segnala i file saltati su stderr (/C serve proprio a continuare), # e sotto 'Stop' quel rumore fermerebbe l'installazione invece di essere un avviso. $ErrorActionPreference = 'Continue' $r = & icacls $folder /grant ("{0}:(OI)(CI)(M)" -f $account) /T /C 2>&1 if ($LASTEXITCODE -ne 0) { Write-Warn2 "icacls on $folder for ${account}: $($r | Select-Object -Last 1)" } else { Write-Ok "granted modify on $folder to $account" } } # The app pool runs as a machine account (IIS APPPOOL\), which has no SQL # login: with Integrated Security the first-run wizard would fail on CREATE # DATABASE / RESTORE. Create the login and make it sysadmin on the local instance # (an evaluation install on a developer machine, same rights the console mode has). function Grant-SqlLogin([string]$dataSource, [string]$account) { $sqlcmd = Get-Command sqlcmd -ErrorAction SilentlyContinue $tsql = "IF SUSER_ID('$account') IS NULL CREATE LOGIN [$account] FROM WINDOWS; ALTER SERVER ROLE sysadmin ADD MEMBER [$account];" if ($sqlcmd) { $r = & sqlcmd -S $dataSource -E -C -b -Q $tsql 2>&1 if ($LASTEXITCODE -eq 0) { Write-Ok "SQL login $account created (sysadmin on $dataSource)"; return $true } Write-Warn2 "sqlcmd could not create the SQL login for ${account}: $($r | Select-Object -Last 1)" return $false } try { $cs = "Data Source=$dataSource;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True;Connect Timeout=10" $cn = New-Object System.Data.SqlClient.SqlConnection $cs $cn.Open() $cmd = $cn.CreateCommand(); $cmd.CommandText = $tsql; $null = $cmd.ExecuteNonQuery() $cn.Close() Write-Ok "SQL login $account created (sysadmin on $dataSource)" return $true } catch { Write-Warn2 "cannot create the SQL login for ${account}: $($_.Exception.Message)" return $false } } # ------------------------------------------------------------------------------------ Write-Host "" Write-Host "WUIC Framework - Windows installer" -ForegroundColor White if ($Src -and -not $PSBoundParameters.ContainsKey('InstallDir')) { $InstallDir = 'C:\dev\WuicTest' } $hostingMode = if ($Src) { 'developer sources (VS Code, dotnet run)' } elseif ($Kestrel) { 'Kestrel (console)' } else { 'IIS' } Write-Host "InstallDir: $InstallDir Port: $Port Tutorial DB: $($WithTutorial.IsPresent) Mode: $hostingMode" -ForegroundColor Gray if (-not [Environment]::Is64BitOperatingSystem) { Fail "64-bit Windows required." 1 } if (-not $Kestrel -and -not $Src -and -not (Test-IsAdmin)) { Invoke-SelfElevate } if ($InstallDir -like "$env:ProgramFiles*") { Fail "Do not install under Program Files: the backend writes settings and logs next to the app." 1 } # 1. winget ----------------------------------------------------------------------------- Write-Step "Checking winget" $hasWinget = Test-Winget if ($hasWinget) { Write-Ok "winget available" } else { Write-Warn2 "winget not found: prerequisites will not be installed automatically (Windows 10 22H2+ / Server 2022+ ship it; otherwise install 'App Installer' from the Microsoft Store)." } # 2. ASP.NET Core Runtime 10 (server) / dev toolchain (-Src) -------------------------------- Refresh-Path $script:DevRebootHint = $false if ($Src) { Write-Step "Checking the developer toolchain (.NET SDK 10, Node.js 22)" if (Test-DotnetSdk10) { Write-Ok ".NET SDK 10 present" } else { if (-not $hasWinget) { Fail ".NET SDK 10 missing. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and rerun." 1 } Invoke-Winget 'Microsoft.DotNet.SDK.10'; $script:DevRebootHint = $true if (-not (Test-DotnetSdk10)) { Fail ".NET SDK 10 installed but not visible yet: open a new PowerShell window (or reboot) and rerun." 1 } Write-Ok ".NET SDK 10 installed" } $nodeMajor = Get-NodeMajor if ($nodeMajor -ge 22) { Write-Ok "Node.js $nodeMajor present" } else { if (-not $hasWinget) { Fail "Node.js 22 LTS missing (found major $nodeMajor). Install it from https://nodejs.org and rerun." 1 } Invoke-Winget 'OpenJS.NodeJS.LTS'; $script:DevRebootHint = $true if ((Get-NodeMajor) -lt 22) { Fail "Node.js installed but not visible yet: open a new PowerShell window and rerun." 1 } Write-Ok "Node.js LTS installed" } # npm on Windows is npm.ps1 for PowerShell hosts: a Restricted policy blocks it in every new terminal (README, section ExecutionPolicy). $pol = Get-ExecutionPolicy -Scope CurrentUser if ($pol -eq 'Undefined' -or $pol -eq 'Restricted') { try { Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force; Write-Ok "PowerShell ExecutionPolicy for the current user set to RemoteSigned (npm.ps1 runs in VS Code terminals)" } catch { Write-Warn2 "Could not set ExecutionPolicy RemoteSigned for the current user: 'npm' in a PowerShell terminal will be blocked until you run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" } } } elseif (Test-AspNetRuntime10) { Write-Step "Checking ASP.NET Core Runtime 10" Write-Ok "Microsoft.AspNetCore.App 10.x present" } else { Write-Step "Checking ASP.NET Core Runtime 10" if (-not $hasWinget) { Fail "ASP.NET Core Runtime 10 missing. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and rerun." 1 } Invoke-Winget 'Microsoft.DotNet.AspNetCore.10' if (-not (Test-AspNetRuntime10)) { Fail "ASP.NET Core Runtime 10 still not visible. Open a new PowerShell window (PATH refresh) and rerun." 1 } Write-Ok "ASP.NET Core Runtime 10 installed" } # 3. Database ------------------------------------------------------------------------------ # Due motori, come fa da sempre l'installer Linux con --dbms. Su Windows l'installer non # configura il database: lo trova (o lo installa) e stampa la stringa di connessione da # incollare nel wizard di primo avvio. Quindi la differenza fra i due percorsi e' tutta qui. $script:SqlMajorVersion = 0 $script:TutorialNeedsScripts = $false $sqlDs = '' if ($Dbms -eq 'mysql') { Write-Step "Looking for a MySQL instance (${MySqlHost}:${MySqlPort})" # Ri-esecuzione dell'one-liner sulla stessa macchina: se la password di root l'ha generata # questo installer, la si rilegge PRIMA di sondare. Si adotta solo se autentica davvero, # cosi' una password stantia (motore reinstallato a mano) lascia il comportamento identico # a prima. Senza questo, la seconda corsa muore su una macchina perfettamente funzionante. $mysqlOk = $false if (-not $MySqlPassword) { $mysqlSalvata = Get-DbSecret 'MYSQL_ROOT_PASSWORD' if ($mysqlSalvata -and (Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $mysqlSalvata)) { $MySqlPassword = $mysqlSalvata $mysqlOk = $true Write-Ok "Reusing the MySQL root password this installer saved in $script:SecretsPath" } } if (-not $mysqlOk) { $mysqlOk = Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $MySqlPassword } if (-not $mysqlOk) { if ($NoSqlInstall) { Fail "No MySQL instance reachable and -NoSqlInstall given. Install MySQL 8+ and rerun with -MySqlHost/-MySqlUser/-MySqlPassword." 1 } if (-not $hasWinget) { Fail "No MySQL instance reachable and winget is missing: install MySQL 8+ and rerun with -MySqlHost/-MySqlUser/-MySqlPassword." 1 } Write-Warn2 "No MySQL found: installing MySQL Community Server (a UAC prompt may appear)." Invoke-Winget 'Oracle.MySQL' $deadline = (Get-Date).AddMinutes(5) while (-not $mysqlOk -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 10 $mysqlOk = Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $MySqlPassword } # L'installer di MySQL su Windows chiede la password di root in modo interattivo: # se non risponde entro il tempo, e' quasi sempre perche' il servizio non e' stato # configurato, non perche' non sia installato. Meglio dirlo che lasciarlo indovinare. # Il pacchetto winget installa i FILE di MySQL ma non inizializza i dati ne' # registra il servizio: dopo l'installazione la porta 3306 non risponde # (verificato 2026-09-08). Si completa qui, che e' la stessa cosa che farebbe il # configuratore grafico: inizializza la directory dati, registra il servizio e lo # avvia. Ogni passo e' condizionato allo stato, quindi non tocca un'installazione # gia' configurata a mano. if (-not $mysqlOk) { $mysqld = (Get-ChildItem 'C:\Program Files\MySQL' -Recurse -Filter 'mysqld.exe' -ErrorAction SilentlyContinue | Select-Object -First 1).FullName if ($mysqld) { $binDir = Split-Path $mysqld -Parent $baseDir = Split-Path $binDir -Parent $dataDir = Join-Path $baseDir 'data' $iniPath = Join-Path $baseDir 'my.ini' Write-Info "Completing the MySQL setup (winget only lays down the files): $baseDir" if (-not (Test-Path $iniPath)) { # Il servizio legge basedir e datadir da qui: senza, non parte. $ini = "[mysqld]`r`nbasedir=$baseDir`r`ndatadir=$dataDir`r`nport=$MySqlPort`r`n" [System.IO.File]::WriteAllText($iniPath, $ini, (New-Object System.Text.UTF8Encoding($false))) } $appenaInizializzato = $false if (-not (Test-Path (Join-Path $dataDir 'mysql'))) { Write-Info "Initializing the data directory" $initOut = & $mysqld "--defaults-file=$iniPath" --initialize-insecure 2>&1 | Out-String if (-not (Test-Path (Join-Path $dataDir 'mysql'))) { Write-Warn2 "mysqld --initialize-insecure did not create the data directory: $($initOut.Trim())" } else { $appenaInizializzato = $true } } if (-not (Get-Service -Name 'MySQL' -ErrorAction SilentlyContinue)) { # L'ordine conta: mysqld vuole --install e SOLO DOPO # --defaults-file. Con l'ordine invertito non registra il servizio e non # lo dice: si scopre al primo Start-Service, con "Cannot find any service # with service name 'MySQL'" (verificato 2026-09-08). $instOut = & $mysqld --install MySQL "--defaults-file=$iniPath" 2>&1 | Out-String if (-not (Get-Service -Name 'MySQL' -ErrorAction SilentlyContinue)) { Write-Warn2 "mysqld --install did not register the service: $($instOut.Trim())" } } try { Start-Service -Name 'MySQL' -ErrorAction Stop } catch { Write-Warn2 "Could not start the MySQL service: $($_.Exception.Message)" } $deadline2 = (Get-Date).AddMinutes(2) while (-not $mysqlOk -and (Get-Date) -lt $deadline2) { Start-Sleep -Seconds 5 $mysqlOk = Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $MySqlPassword } if ($mysqlOk -and $appenaInizializzato -and -not $MySqlPassword) { # --initialize-insecure lascia root SENZA password. L'installer Linux non # si ferma li': genera una password forte e la salva in # /etc/wuiccore/secrets.env (21-install-mysql.sh). Windows deve fare lo # stesso, per due ragioni. La prima e' la sicurezza: un root senza # password su una macchina Windows e' peggio che su un container. La # seconda e' che il prodotto stesso rifiuta quella configurazione: il # wizard di primo avvio accetta la stringa per il TEST di connessione e # poi la respinge alla conferma con "servono almeno user e password" # (verificato 2026-09-08). L'installer lasciava quindi la macchina in uno # stato che il suo stesso wizard non sa usare. $MySqlPassword = Get-RandomPassword $sqlPwd = "ALTER USER 'root'@'localhost' IDENTIFIED BY '" + ($MySqlPassword -replace "'", "''") + "'; FLUSH PRIVILEGES;" $mysqlExe = Join-Path $binDir 'mysql.exe' # L'istruzione passa da STDIN e non da -e: la riga di comando di un processo # e' leggibile da chiunque sia sulla macchina, e con -e la password nuova ci # comparirebbe in chiaro. mysql.exe avverte anche lui, scrivendo # "Using a password on the command line interface can be insecure" su stderr. # # E quell'avviso, non l'errore, e' quello che ha fatto fallire l'installazione # il 2026-09-08: con $ErrorActionPreference = 'Stop' (impostato in cima allo # script) PowerShell 5.1 trasforma QUALUNQUE riga su stderr di un comando # nativo in eccezione terminante. L'installer moriva con exit 1 subito dopo # aver impostato la password correttamente. Quindi il preferenziale si abbassa # localmente e l'esito si giudica dal risultato, non dall'assenza di rumore. $ErrorActionPreference = 'Continue' $altOut = ($sqlPwd | & $mysqlExe "--host=$MySqlHost" "--port=$MySqlPort" '-u' 'root' '--skip-password' 2>&1 | Out-String) $ErrorActionPreference = 'Stop' $mysqlOk = Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $MySqlPassword if ($mysqlOk) { Save-DbSecret 'MYSQL_ROOT_PASSWORD' $MySqlPassword Write-Ok "MySQL root password set and saved in $script:SecretsPath" } else { Write-Warn2 "Could not set the MySQL root password: $($altOut.Trim()). root stays without a password and the first-run wizard will refuse the connection string." $MySqlPassword = '' $mysqlOk = Test-MySqlDataSource $MySqlHost $MySqlPort $MySqlUser $MySqlPassword } } } } # Due guasti diversi, due diagnosi diverse: mandare a controllare servizio e porta chi # ha invece un problema di credenziali costa mezz'ora a chi legge (verificato 2026-09-10). if (-not $mysqlOk) { if (Test-TcpPort $MySqlHost $MySqlPort) { Fail "MySQL answers on ${MySqlHost}:${MySqlPort} but refused the login for user '$MySqlUser': rerun with -MySqlUser/-MySqlPassword. If this machine was set up by this installer, the root password it generated is in $script:SecretsPath." 1 } Fail "MySQL installed but ${MySqlHost}:${MySqlPort} does not answer: finish the MySQL configuration (root password and Windows service) and rerun with -MySqlUser/-MySqlPassword." 1 } } $sqlDs = "${MySqlHost}:${MySqlPort}" $mysqlPwdPart = '' if ($MySqlPassword) { $mysqlPwdPart = "Pwd=$MySqlPassword;" } $dataConnection = "Server=$MySqlHost;Port=$MySqlPort;Uid=$MySqlUser;${mysqlPwdPart}Database=WuicData;SslMode=None;AllowPublicKeyRetrieval=True" # Il tutorial c'e' anche su MySQL, ma solo nella variante con gli script SQL: il .bak # e' un backup nativo di SQL Server e non ha equivalente. La scelta del pacchetto # avviene piu' avanti; qui si registra che la variante veloce non e' disponibile. $script:TutorialNeedsScripts = $true } elseif ($Dbms -eq 'postgres') { Write-Step "Looking for a PostgreSQL instance (${PostgresHost}:${PostgresPort})" # Stessa ragione del ramo MySQL: la password del superutente l'ha generata e salvata # questo installer, quindi alla ri-esecuzione si rilegge invece di rifare da capo il giro # con `trust` sul loopback. $pgOk = $false if (-not $PostgresPassword) { $pgSalvata = Get-DbSecret 'POSTGRES_PASSWORD' if ($pgSalvata -and (Test-PostgresDataSource $PostgresHost $PostgresPort $PostgresUser $pgSalvata)) { $PostgresPassword = $pgSalvata $pgOk = $true Write-Ok "Reusing the PostgreSQL superuser password this installer saved in $script:SecretsPath" } } if (-not $pgOk) { $pgOk = Test-PostgresDataSource $PostgresHost $PostgresPort $PostgresUser $PostgresPassword } if (-not $pgOk) { if ($NoSqlInstall) { Fail "No PostgreSQL instance reachable and -NoSqlInstall given. Install PostgreSQL 14+ and rerun with -PostgresHost/-PostgresUser/-PostgresPassword." 1 } if (-not $hasWinget) { Fail "No PostgreSQL instance reachable and winget is missing: install PostgreSQL 14+ and rerun with -PostgresHost/-PostgresUser/-PostgresPassword." 1 } Write-Warn2 "No PostgreSQL found: installing PostgreSQL (a UAC prompt may appear)." $pgId = Resolve-WingetVersionedId 'PostgreSQL.PostgreSQL' 'PostgreSQL.PostgreSQL' Write-Info "winget package: $pgId" Invoke-Winget $pgId # winget installa il servizio ma lascia il superutente con una password che chi # installa non conosce: il wizard di primo avvio chiede una stringa di connessione # completa e senza password non si autentica (verificato 2026-09-08, il test di # connessione andava in timeout). Come per MySQL, l'installer se ne fa carico: apre # temporaneamente l'accesso locale in `trust`, imposta una password generata, la # salva e richiude. E' quello che farebbe un amministratore a mano, ed e' anche # l'unico modo di uscire dal circolo "per impostare la password serve la password". if (-not $PostgresPassword) { $pgBin = Get-ChildItem 'C:/Program Files/PostgreSQL' -Recurse -Filter 'psql.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 $pgHba = Get-ChildItem 'C:/Program Files/PostgreSQL' -Recurse -Filter 'pg_hba.conf' -ErrorAction SilentlyContinue | Select-Object -First 1 $pgSvc = Get-Service -Name 'postgresql*' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($pgBin -and $pgHba -and $pgSvc) { Write-Step "Completing the PostgreSQL setup (winget does not leave a known superuser password)" $ErrorActionPreference = 'Continue' $originale = Get-Content $pgHba.FullName -Raw try { # `trust` SOLO su 127.0.0.1 e SOLO per il tempo di impostare la password. # Vanno aperte ENTRAMBE le righe di loopback, IPv4 e IPv6: su Windows # `localhost` risolve prima in ::1, quindi aprendo solo 127.0.0.1 il client # arriva su una riga rimasta a scram-sha-256, psql chiede la password sul # terminale e resta li' per sempre. Verificato sulla VM il 2026-09-08: psql # appeso da dieci minuti, installer fermo senza una riga di output, e # soprattutto il `finally` qui sotto MAI eseguito, quindi la macchina # rimasta con `trust` aperto sul loopback a tempo indeterminato: l'attesa # non era solo uno stallo, era una finestra di accesso lasciata aperta. $temporaneo = ($originale -split "`r?`n" | ForEach-Object { if ($_ -match '^\s*host\s' -and ($_ -match '127\.0\.0\.1' -or $_ -match '::1')) { $_ -replace '(scram-sha-256|md5|password)\s*$', 'trust' } else { $_ } }) -join [Environment]::NewLine [System.IO.File]::WriteAllText($pgHba.FullName, $temporaneo, (New-Object System.Text.UTF8Encoding($false))) Restart-Service -Name $pgSvc.Name -Force Start-Sleep -Seconds 5 $PostgresPassword = Get-RandomPassword $sqlPg = "ALTER USER ${PostgresUser} WITH PASSWORD '" + ($PostgresPassword -replace "'", "''") + "';" $o = & $pgBin.FullName '-w' '-h' $PostgresHost '-p' "$PostgresPort" '-U' $PostgresUser '-d' 'postgres' '-c' $sqlPg 2>&1 | Out-String if ($o -notmatch 'ALTER ROLE') { Write-Warn2 "ALTER USER su PostgreSQL: $($o.Trim())" } } finally { # La configurazione originale torna SEMPRE al suo posto, anche se # qualcosa e' andato storto: lasciare `trust` su una macchina e' peggio # del problema che si stava risolvendo. [System.IO.File]::WriteAllText($pgHba.FullName, $originale, (New-Object System.Text.UTF8Encoding($false))) Restart-Service -Name $pgSvc.Name -Force Start-Sleep -Seconds 5 $ErrorActionPreference = 'Stop' } if (Test-PostgresDataSource $PostgresHost $PostgresPort $PostgresUser $PostgresPassword) { Save-DbSecret 'POSTGRES_PASSWORD' $PostgresPassword Write-Ok "PostgreSQL superuser password set and saved in $script:SecretsPath" } else { Write-Warn2 "Could not set the PostgreSQL superuser password: rerun with -PostgresUser/-PostgresPassword of an instance you configured." $PostgresPassword = '' } } else { Write-Warn2 "PostgreSQL is installed but psql/pg_hba.conf/the service were not found: set the superuser password by hand." } } $deadline = (Get-Date).AddMinutes(5) while (-not $pgOk -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 10 $pgOk = Test-PostgresDataSource $PostgresHost $PostgresPort $PostgresUser $PostgresPassword } if (-not $pgOk) { if (Test-TcpPort $PostgresHost $PostgresPort) { Fail "PostgreSQL answers on ${PostgresHost}:${PostgresPort} but refused the login for user '$PostgresUser': rerun with -PostgresUser/-PostgresPassword. If this machine was set up by this installer, the password it generated is in $script:SecretsPath." 1 } Fail "PostgreSQL installed but ${PostgresHost}:${PostgresPort} does not answer: finish its configuration (superuser password and Windows service) and rerun with -PostgresUser/-PostgresPassword." 1 } } # PostGIS: il tutorial per PostgreSQL lo richiede. tutorial-data.postgres.sql apre con # CREATE EXTENSION postgis e usa tipi geografici in oltre mille righe, e anche # minimal-metadata.postgres.sql lo cita. Su Linux lo mette lo step 23 dell'installer; # qui winget posa solo PostgreSQL, quindi il caricamento moriva a meta' con # "extension postgis is not available" e sembrava un difetto degli script SQL # (journey win-iis-tutorial-bak/postgres, 2026-09-10). L'unico installer non presidiato # per Windows e' il bundle OSGeo, un NSIS: accetta /S e /D= (verificato sul file 2026-09-11). $ErrorActionPreference = 'Continue' $psqlPath = Find-DbClient 'C:/Program Files/PostgreSQL' 'psql.exe' $sqlPostgis = "SELECT count(*) FROM pg_available_extensions WHERE name='postgis'" $postgisPronto = $false if ($psqlPath) { $env:PGPASSWORD = $PostgresPassword $n = & $psqlPath -w -h $PostgresHost -p $PostgresPort -U $PostgresUser -d postgres -tAc $sqlPostgis 2>&1 Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue $postgisPronto = ($LASTEXITCODE -eq 0 -and ([string]$n).Trim() -match '^[1-9]') } if ($postgisPronto) { Write-Ok "PostGIS extension available on ${PostgresHost}:${PostgresPort}" } elseif ($NoSqlInstall) { Write-Warn2 "PostGIS is not available on ${PostgresHost}:${PostgresPort} and -NoSqlInstall was given: the tutorial data will not load. Install PostGIS on that instance and rerun." } elseif (-not $psqlPath -or ($PostgresHost -notin @('localhost', '127.0.0.1', '::1'))) { Write-Warn2 "PostGIS is not available on ${PostgresHost}:${PostgresPort}: this installer can only add it to a local instance. Install PostGIS there and rerun." } else { Write-Step "Installing PostGIS (the tutorial database needs it)" $pgHome = Split-Path (Split-Path $psqlPath -Parent) -Parent # C:\Program Files\PostgreSQL\ $pgMajor = Split-Path $pgHome -Leaf $elenco = "https://download.osgeo.org/postgis/windows/pg${pgMajor}/" # Il nome del bundle porta la versione di PostGIS, che cambia: si legge l'elenco e si # prende l'ultima, invece di fissare un numero che fra sei mesi non esiste piu'. $bundle = '' $progressoPrec = $ProgressPreference $ProgressPreference = 'SilentlyContinue' # la barra di avanzamento di Invoke-WebRequest rallenta di 10x un download da 100 MB try { $html = (Invoke-WebRequest -Uri $elenco -UseBasicParsing).Content $nomi = [regex]::Matches($html, "postgis-bundle-pg${pgMajor}x64-setup-[0-9.]+-[0-9]+\.exe") | ForEach-Object { $_.Value } | Sort-Object -Unique $bundle = $nomi | Sort-Object { [version](($_ -replace '^.*-setup-', '') -replace '-[0-9]+\.exe$', '') } | Select-Object -Last 1 } catch { $bundle = '' } if (-not $bundle) { $ProgressPreference = $progressoPrec Fail "Could not find a PostGIS bundle for PostgreSQL $pgMajor at $elenco (no network, or no bundle published for this major yet). Install PostGIS by hand and rerun." 1 } $setup = Join-Path $env:TEMP $bundle Write-Info "downloading $elenco$bundle" try { Invoke-WebRequest -Uri ($elenco + $bundle) -OutFile $setup -UseBasicParsing } finally { $ProgressPreference = $progressoPrec } # NSIS: /S = silenzioso; /D= deve essere l'ULTIMO argomento e SENZA virgolette anche # se il percorso contiene spazi. E' la convenzione NSIS, non una svista. $p = Start-Process -FilePath $setup -ArgumentList "/S /D=$pgHome" -Wait -PassThru if ($p.ExitCode -ne 0) { Fail "PostGIS installer exited with code $($p.ExitCode)." 1 } Remove-Item $setup -ErrorAction SilentlyContinue $env:PGPASSWORD = $PostgresPassword $n = & $psqlPath -w -h $PostgresHost -p $PostgresPort -U $PostgresUser -d postgres -tAc $sqlPostgis 2>&1 Remove-Item Env:\PGPASSWORD -ErrorAction SilentlyContinue if ($LASTEXITCODE -eq 0 -and ([string]$n).Trim() -match '^[1-9]') { Write-Ok "PostGIS installed into $pgHome" } else { Fail "PostGIS was installed but PostgreSQL does not list the extension: check $pgHome\share\extension\postgis.control and rerun." 1 } } $ErrorActionPreference = 'Stop' $sqlDs = "${PostgresHost}:${PostgresPort}" $pgPwdPart = '' if ($PostgresPassword) { $pgPwdPart = "Password=$PostgresPassword;" } $dataConnection = "Host=$PostgresHost;Port=$PostgresPort;Username=$PostgresUser;${pgPwdPart}Database=WuicData" $script:TutorialNeedsScripts = $true } elseif ($Dbms -eq 'oracle') { # Oracle Database non ha un installer non presidiato su Windows: winget pubblica solo # Instant Client e SQLcl (verificato 2026-09-08). L'istanza deve esistere gia', via # Oracle XE installato a mano oppure in un container. Write-Step "Looking for an Oracle listener (${OracleHost}:${OraclePort})" if (-not (Test-OracleDataSource $OracleHost $OraclePort)) { Fail "No Oracle listener on ${OracleHost}:${OraclePort}. Oracle Database has no unattended installer on Windows: install Oracle XE (or run it in a container), then rerun with -OracleHost/-OraclePort/-OracleService/-OracleUser/-OraclePassword." 1 } $sqlDs = "${OracleHost}:${OraclePort}/${OracleService}" $dataConnection = "User Id=$OracleUser;Password=$OraclePassword;Data Source=${OracleHost}:${OraclePort}/${OracleService}" $script:TutorialNeedsScripts = $true } else { Write-Step "Looking for a SQL Server instance (Windows authentication)" $sqlDs = Find-SqlServer if (-not $sqlDs) { if ($NoSqlInstall) { Fail "No SQL Server instance reachable and -NoSqlInstall given. Pass -SqlServer ." 1 } if (-not $hasWinget) { Fail "No SQL Server instance reachable and winget is missing: install SQL Server 2019+ (Express is enough for evaluation) and rerun with -SqlServer." 1 } $sqlExpressId = Resolve-SqlExpressId Write-Warn2 "No instance found: installing $sqlExpressId (instance SQLEXPRESS, 5-15 minutes, UAC prompt)." Invoke-Winget $sqlExpressId $deadline = (Get-Date).AddMinutes(5) while (-not $sqlDs -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 10 if (Test-SqlDataSource "localhost\SQLEXPRESS") { $sqlDs = "localhost\SQLEXPRESS" } } if (-not $sqlDs) { Fail "SQL Server Express installed but localhost\SQLEXPRESS does not answer yet. Check the service 'SQL Server (SQLEXPRESS)' and rerun with -SqlServer 'localhost\SQLEXPRESS'." 1 } } $dataConnection = "Data Source=$sqlDs;Integrated Security=SSPI;Initial Catalog=WuicData;Encrypt=False;TrustServerCertificate=True" } # Come si autentica la stringa che il wizard ricevera', e dove ritrovare la password se e' # stato l'installer a generarla. Le due righe finali dicevano "(Windows authentication)" a # chiunque, anche sotto una stringa MySQL che porta utente e password in chiaro, e non # nominavano mai il file dei segreti: chi riapriva il wizard il giorno dopo non aveva modo # di sapere dove fosse finita la password. $authNote = 'Windows authentication' $secretNote = '' if ($Dbms -eq 'mysql') { $authNote = "user '$MySqlUser'$(if ($MySqlPassword) { ' and password, already in the string' } else { ', no password' })" if ($MySqlPassword -and (Test-Path $script:SecretsPath)) { $secretNote = "The MySQL root password is also saved in $script:SecretsPath" } } elseif ($Dbms -eq 'postgres') { $authNote = "user '$PostgresUser'$(if ($PostgresPassword) { ' and password, already in the string' } else { ', no password' })" if ($PostgresPassword -and (Test-Path $script:SecretsPath)) { $secretNote = "The PostgreSQL superuser password is also saved in $script:SecretsPath" } } elseif ($Dbms -eq 'oracle') { $authNote = "user '$OracleUser' and password, already in the string" } # The tutorial package ships .bak backups that SQL Server itself opens (RESTORE runs in the # service process, not as the user): a local instance whose service account cannot read # the install folder fails with "Cannot open backup device ... Operating system error 5". # Under %LOCALAPPDATA% that is the default outcome, so grant the instance's service SID # read+execute on the install folder. LocalDB runs as the current user: nothing to do. function Grant-SqlServiceReadAccess([string]$dataSource, [string]$folder) { if ($dataSource -like '(localdb)*') { return } # host part of "host\instance,port" without regex (PowerShell 5.1 quoting of backslashes is error-prone) $host0 = $dataSource.Split('\')[0].Split(',')[0].ToLowerInvariant() $isLocal = @('localhost', '.', '127.0.0.1', $env:COMPUTERNAME.ToLowerInvariant()) -contains $host0 if (-not $isLocal) { Write-Info "SQL Server is remote ($dataSource): make sure it can read the .bak files, or use the SQL variant of the tutorial package."; return } $instance = '' if ($dataSource.Contains('\')) { $instance = ($dataSource.Split('\')[1] -split ',')[0] } $account = if ($instance -and $instance.ToUpperInvariant() -ne 'MSSQLSERVER') { 'NT SERVICE\MSSQL$' + $instance } else { 'NT SERVICE\MSSQLSERVER' } New-Item -ItemType Directory -Path $folder -Force | Out-Null $p = Start-Process -FilePath 'icacls.exe' -ArgumentList @("`"$folder`"", '/grant', "`"${account}:(OI)(CI)RX`"", '/T', '/Q') -Wait -PassThru -NoNewWindow if ($p.ExitCode -eq 0) { Write-Ok "granted read access on $folder to $account (SQL Server restores the tutorial .bak from there)" } else { Write-Warn2 "could not grant $account read access on $folder (icacls exit $($p.ExitCode)): the tutorial restore may fail with 'Operating system error 5'" } } # Only the tutorial packages carry a .bak that SQL Server itself must open: granting the # service account read access on a developer workspace (node_modules included) is minutes of # icacls for nothing. if ($WithTutorial -and $Dbms -eq 'mssql') { Grant-SqlServiceReadAccess $sqlDs $InstallDir } # 4. Release + download ----------------------------------------------------------------------- Write-Step "Resolving the release" $wantTutorial = 'no' if ($WithTutorial) { # The tutorial .bak is a SQL Server 2022 backup: 2019 cannot restore it ("backed up on a server running # version 16.00 ... incompatible"). On older instances fall back to the SQL-script variant (slower restore, same data). if ($script:TutorialNeedsScripts) { # Su MySQL (e su qualunque motore diverso da SQL Server) il .bak non si puo' # ripristinare: si usa la variante con gli script, che esiste per tutti i motori # (dbms/scripts/first-run/tutorial-*..sql). $wantTutorial = 'SQL' Write-Info "Tutorial su ${Dbms}: uso il pacchetto con gli script SQL (il .bak e' un backup nativo di SQL Server)." } elseif ($script:SqlMajorVersion -ge 16) { $wantTutorial = 'BAK' } else { $wantTutorial = 'SQL'; Write-Warn2 "SQL Server $($script:SqlMajorVersion) cannot restore the 2022 .bak: using the tutorial package with SQL scripts instead (restore takes minutes instead of seconds)." } } $audience = if ($Src) { 'src' } else { 'iis' } $wantRag = -not ($Src -and $NoRag) if ($PackageUrl) { # Pacchetto imposto da chi lancia: niente manifesto e nessuna scelta della variante. # Chi passa -PackageUrl sa gia' quale artefatto vuole, e pretendere che compaia anche # in releases.json renderebbe il parametro inutile proprio nel caso per cui esiste: # una build che non e' ancora pubblicata. La variante (tutorial, audience) resta # calcolata sopra perche' serve al resto dell'installazione, non alla scelta del file. # Split di STRINGA, non regex: `-split` interpreta il separatore come espressione # regolare e '?' da solo e' un quantificatore senza nulla davanti, quindi qualunque # -PackageUrl faceva terminare lo script con "parsing '?' - Quantifier {x,y} following # nothing" prima ancora di scaricare (verificato sul guest il 2026-09-09). $nomePkg = [IO.Path]::GetFileName($PackageUrl.Split([char]63)[0]) if (-not $nomePkg) { $nomePkg = 'wuic-package.zip' } $key = 'custom' $pkg = New-Object PSObject -Property @{ name = $nomePkg; url = $PackageUrl; size = 'n/d'; audience = $audience; tutorial = $wantTutorial; rag = $wantRag } Write-Info "Package imposed with -PackageUrl: releases.json is not consulted" } else { $manifest = Invoke-RestMethod -Uri "$BaseUrl/downloads/releases.json" -TimeoutSec 30 $key = if ($Version -eq 'latest') { $manifest.latest } else { $Version } $release = $manifest.releases | Where-Object { $_.key -eq $key } | Select-Object -First 1 if (-not $release) { Fail "Release '$key' not found in releases.json (available: $(($manifest.releases | ForEach-Object { $_.key }) -join ', '))." 2 } $pkg = $release.files | Where-Object { $_.audience -eq $audience -and $_.rag -eq $wantRag -and $_.tutorial -eq $wantTutorial } | Select-Object -First 1 if (-not $pkg) { $pkg = $release.files | Where-Object { $_.audience -eq $audience -and $_.tutorial -eq $wantTutorial } | Select-Object -First 1 } } if (-not $pkg) { Fail "No $audience package with tutorial='$wantTutorial' in release $key." 2 } Write-Ok "Release $key - $($pkg.name) ($($pkg.size))" # Server installs keep the app in \app; the sources package goes straight into # (the README wants the files at the root of the working folder, not under src\). $downloadDir = if ($Src) { Join-Path $env:LOCALAPPDATA 'WUIC\downloads' } else { Join-Path $InstallDir 'downloads' } $appDir = if ($Src) { $InstallDir } else { Join-Path $InstallDir 'app' } New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null $zipPath = Join-Path $downloadDir $pkg.name # Il manifesto elenca percorsi relativi al sito; -PackageUrl invece porta gia' un URL # completo o un percorso locale, che concatenato a BaseUrl diventerebbe irraggiungibile. $zipUrl = if ($pkg.url -match '^https?://') { $pkg.url } elseif ($PackageUrl) { $pkg.url } else { "$BaseUrl$($pkg.url)" } $installedMarker = Join-Path $appDir 'wuic-install.json' $alreadyInstalled = $false $presentFile = if ($Src) { 'WuicTest.csproj' } else { 'WuicTest.dll' } if ((Test-Path $installedMarker) -and (Test-Path (Join-Path $appDir $presentFile))) { try { $prev = Get-Content $installedMarker -Raw | ConvertFrom-Json; if ($prev.package -eq $pkg.name) { $alreadyInstalled = $true } } catch { } } if ($alreadyInstalled) { Write-Ok "Package $($pkg.name) already installed in $appDir (delete the folder to reinstall)" } else { # Extraction empties the target folder. For -Src that folder is the developer's working # directory, so refuse when it holds anything this installer did not put there. # I file che questo installer ha gia' scritto li' NON contano come "contenuto altrui": # con -Dbms mysql/postgres i segreti del motore finiscono in $InstallDir (Save-DbSecret) # PRIMA di arrivare qui, e su una macchina pulita la cartella conteneva solo quel file # da 64 byte. La guardia lo scambiava per roba dello sviluppatore e l'installer # rifiutava la cartella che aveva creato lui stesso (journey win-vscode-src/mysql, # 2026-09-11: exit 2 dodici secondi dopo aver scritto il file). $fileDellInstaller = @('wuic-secrets.json', 'wuic-install.json') if ($Src -and (Test-Path $appDir)) { $existing = @(Get-ChildItem -Path $appDir -Force -ErrorAction SilentlyContinue | Where-Object { $fileDellInstaller -notcontains $_.Name }) if ($existing.Count -gt 0 -and -not (Test-Path $installedMarker)) { Fail "$appDir is not empty and was not created by this installer (extraction would delete its content). Choose an empty folder with -InstallDir, or remove it yourself." 2 } } Write-Step "Downloading the package" Download-File $zipUrl $zipPath Write-Step "Extracting the package" # Expand-Package svuota la cartella prima di estrarre. Se i segreti del motore stanno # dentro (-Src con -InstallDir = cartella del progetto) sparirebbero con la password di # root appena generata, e ne' il wizard ne' chi installa la conoscerebbero piu'. $segretiDaConservare = $null if ((Test-Path $script:SecretsPath) -and ($script:SecretsPath -like (Join-Path $appDir '*'))) { $segretiDaConservare = [System.IO.File]::ReadAllBytes($script:SecretsPath) } Expand-Package $zipPath $appDir $presentFile if ($segretiDaConservare) { [System.IO.File]::WriteAllBytes($script:SecretsPath, $segretiDaConservare) Write-Info "kept $script:SecretsPath across the extraction" } $markerJson = @{ package = $pkg.name; release = $key; installedAt = (Get-Date).ToString('o'); sqlDataSource = $sqlDs; port = $Port } | ConvertTo-Json # UTF-8 without BOM: Set-Content -Encoding UTF8 on Windows PowerShell 5.1 writes a BOM that strict JSON parsers reject. [System.IO.File]::WriteAllText($installedMarker, $markerJson, (New-Object System.Text.UTF8Encoding($false))) } # 5-dev. Developer setup (-Src): restore, npm install, VS Code extension, next steps ---------------- if ($Src) { Invoke-Timed 'dotnet restore' 'dotnet' @('restore', 'WuicTest.csproj', '--nologo') $appDir $wwwroot = Join-Path $appDir 'wwwroot' if (-not (Test-Path (Join-Path $wwwroot 'package.json'))) { Fail "wwwroot\package.json not found under $appDir : the package layout is not the expected one." 2 } # npm 10.9.x - the version Node 22 LTS ships - cannot resolve this dependency graph from # scratch: it stops with "Cannot read properties of null (reading 'edgesOut')" inside the peer # resolution of vitest 4 / @angular/build 21 (verified 2026-09-07 on a clean Windows 10). # With a package-lock.json in place the same npm installs the tree in seconds, and every # developer gets the same tree. Packages built from 1.7.1 on ship the lock; for the older ones # it is published next to the release, so fetch it when the extracted package has none. $lockPath = Join-Path $wwwroot 'package-lock.json' if (-not (Test-Path $lockPath) -and -not $NoLockDownload -and $PackageUrl) { # Il lock si pubblica accanto a una release: per un pacchetto imposto con # -PackageUrl non esiste, e chiederlo al sito otterrebbe la home della SPA. Write-Info "Package imposed with -PackageUrl and no package-lock.json inside: npm will resolve the tree by itself." } elseif (-not (Test-Path $lockPath) -and -not $NoLockDownload) { $lockUrl = "$BaseUrl/downloads/locks/$($pkg.name).package-lock.json" Write-Step "Fetching the dependency lock for this release" Write-Info $lockUrl try { Invoke-WebRequest -Uri $lockUrl -OutFile $lockPath -UseBasicParsing -TimeoutSec 60 # Il sito e' una single-page app: per un percorso inesistente risponde 200 con la # home, non 404. Senza questo controllo si scriveva l'HTML dentro # package-lock.json, l'installer dichiarava "npm install sara' riproducibile" e # npm falliva lo stesso sui peer (verificato 2026-09-08: 112 KB di HTML). # La validazione NON passa da ConvertFrom-Json: in Windows PowerShell 5.1 quel # cmdlet non riesce a convertire un package-lock.json vero. I lock # `lockfileVersion 3` hanno dentro `packages` una chiave VUOTA ("") — e' il # pacchetto radice — e 5.1 non sa creare una proprieta' senza nome: esce con # "Cannot process argument because the value of argument name is not valid". # Risultato: un lock perfettamente valido veniva scartato SEMPRE, cancellato, e # l'installer dichiarava "probabilmente la pagina catch-all del sito" mentre aveva # in mano 616 KB di JSON corretto. Poi npm crollava sui peer e ripiegava su # --legacy-peer-deps, che sembrava una rete di sicurezza ed era invece l'unico # esito possibile (verificato dentro il guest il 2026-09-09). # Serve distinguere un lock dalla pagina HTML del sito, e per quello bastano il # primo carattere e la presenza della chiave: nessun parsing, nessuna dipendenza # dalla versione di PowerShell. $lockOk = $false try { $lockHead = Get-Content $lockPath -Raw -ErrorAction Stop $lockOk = ($lockHead.TrimStart().StartsWith('{')) -and ($lockHead -match '"lockfileVersion"') } catch { $lockOk = $false } if (-not $lockOk) { Remove-Item $lockPath -Force -ErrorAction SilentlyContinue Write-Warn2 "The URL answered but did not return a lock file (probably the site's catch-all page): npm install may need --legacy-peer-deps." } else { $lockKb = [math]::Round((Get-Item $lockPath).Length / 1KB) Write-Ok "package-lock.json ($lockKb KB): npm install will be reproducible" } } catch { if (Test-Path $lockPath) { Remove-Item $lockPath -Force } Write-Warn2 "No published lock for this release ($($_.Exception.Message)). npm install may need --legacy-peer-deps." } } Invoke-Timed 'npm install (wwwroot)' 'npm.cmd' @('install', '--no-audit', '--no-fund') $wwwroot ` @('install', '--legacy-peer-deps', '--no-audit', '--no-fund') ` 'npm could not resolve the peer dependencies of the project: retrying with --legacy-peer-deps (same packages, no peer resolution).' $vsix = Join-Path $appDir 'llm-workspace\plugin\wuic-assistant.vsix' $codeCmd = Get-Command code -ErrorAction SilentlyContinue if ($codeCmd -and (Test-Path $vsix)) { Write-Step "Installing the WUIC Assistant extension in VS Code" & code --install-extension $vsix --force 2>&1 | Where-Object { $_ -match 'installed|already|rror' } | ForEach-Object { Write-Info $_ } Write-Ok "WUIC Assistant extension installed (Settings > WUIC Assistant to pick the LLM provider)" } elseif (-not $codeCmd) { Write-Warn2 "VS Code ('code') not on the PATH: install the WUIC Assistant later with: code --install-extension `"$vsix`"" } else { Write-Warn2 "wuic-assistant.vsix not found in the package ($vsix)" } $workspaceFile = Get-ChildItem -Path $appDir -Filter '*.code-workspace' -ErrorAction SilentlyContinue | Select-Object -First 1 # Attenzione a come e' scritto: '.\' si chiude PRIMA di 'rename-project.ps1'. Tutto d'un # pezzo, il "\r" viene interpretato come ritorno a capo dagli strumenti che passano su # questo file, e la riga stampata diventava "Rename the project: ." seguita da # "ename-project.ps1" - cioe' un comando che non esiste (era cosi' fino al 2026-09-10). $renameHint = '.\' + 'rename-project.ps1' Write-Host "" Write-Host "Developer workspace ready: $appDir" -ForegroundColor Green Write-Host "" if ($workspaceFile) { Write-Host " code `"$($workspaceFile.FullName)`"" -ForegroundColor Yellow } else { Write-Host " code `"$appDir`"" -ForegroundColor Yellow } Write-Host " then F5 > 'Fullstack: WuicTest + Chrome' (backend http://localhost:5000, frontend http://localhost:4200)" -ForegroundColor White Write-Host " or from a terminal: dotnet run and cd wwwroot; npm run serve:npm" -ForegroundColor Gray Write-Host "" Write-Host "First start opens the first-run wizard: paste this connection string ($authNote)" -ForegroundColor White Write-Host " $dataConnection" -ForegroundColor Yellow if ($secretNote) { Write-Host " $secretNote" -ForegroundColor Gray } if ($WithTutorial) { Write-Host " or choose setup mode 'Tutorial WideWorldImporters' to provision the demo database." -ForegroundColor Gray } Write-Host "Rename the project: $renameHint -Name MyApp (any PowerShell, 5.1 included; README.md explains the rest)" -ForegroundColor Gray if ($script:DevRebootHint) { Write-Warn2 "A toolchain component was installed: reboot (or at least sign out) before opening VS Code, otherwise it may not see dotnet/node on the PATH." } Write-Host "Docs: $BaseUrl/docs/getting-started" -ForegroundColor Gray exit 0 } # 5. Launcher (Kestrel mode) -------------------------------------------------------------------- $startCmd = Join-Path $appDir 'start-wuic.cmd' $bindHost = if ($ListenAll) { '0.0.0.0' } else { 'localhost' } @" @echo off rem WUIC backend on Kestrel - generated by install.ps1. Close this window to stop. cd /d "%~dp0" set ASPNETCORE_ENVIRONMENT=Production set ASPNETCORE_URLS=http://$bindHost`:$Port echo WUIC listening on http://$bindHost`:$Port (Ctrl+C or close the window to stop) dotnet WuicTest.dll --urls http://$bindHost`:$Port "@ | Set-Content -Path $startCmd -Encoding ASCII Write-Ok "Launcher written: $startCmd" # 6. IIS site (default) -------------------------------------------------------------------------- $siteName = 'WUIC' $poolName = 'WUIC' $poolAccount = "IIS APPPOOL\$poolName" if (-not $Kestrel) { Write-Step "Publishing the app in IIS (site '$siteName', port $Port)" if (-not (Test-IisPresent)) { Write-Warn2 "IIS is not installed: enabling the Windows features (this can take a few minutes)." Enable-IisFeatures if (-not (Test-IisPresent)) { Fail "IIS could not be enabled on this machine. Rerun with -Kestrel to run the app on Kestrel instead (no IIS, no administrator rights)." 4 } } Write-Ok "IIS present" if (Test-HostingBundle) { Write-Ok "ASP.NET Core Hosting Bundle present" } else { if (-not $hasWinget) { Fail "ASP.NET Core Hosting Bundle missing and winget is not available: install it from https://dotnet.microsoft.com/download/dotnet/10.0 (Hosting Bundle) and rerun, or use -Kestrel." 4 } Write-Warn2 "Hosting Bundle missing: installing Microsoft.DotNet.HostingBundle.10" Invoke-Winget 'Microsoft.DotNet.HostingBundle.10' & iisreset /restart | Out-Null if (-not (Test-HostingBundle)) { Fail "Hosting Bundle installed but aspnetcorev2.dll is still missing. Reboot and rerun, or use -Kestrel." 4 } Write-Ok "Hosting Bundle installed" } # App pool: No Managed Code (the module hosts .NET out of process). $pools = (Invoke-AppCmd @('list', 'apppool')).out if ($pools -match ('APPPOOL "' + $poolName + '"')) { Write-Ok "application pool '$poolName' already there" } else { $r = Invoke-AppCmd @('add', 'apppool', "/name:$poolName") if ($r.code -ne 0) { Fail "Cannot create the application pool '$poolName': $($r.out)" 4 } Write-Ok "application pool '$poolName' created" } $null = Invoke-AppCmd @('set', 'apppool', "/apppool.name:$poolName", '/managedRuntimeVersion:', '/startMode:AlwaysRunning', '/processModel.idleTimeout:00:00:00') # Site on the requested port. -ListenAll binds every interface, otherwise localhost only. $binding = if ($ListenAll) { "http/*:${Port}:" } else { "http/*:${Port}:localhost" } $sites = (Invoke-AppCmd @('list', 'site')).out if ($sites -match ('SITE "' + $siteName + '"')) { $null = Invoke-AppCmd @('set', 'site', "/site.name:$siteName", "/[path='/'].[path='/'].physicalPath:$appDir") Write-Ok "site '$siteName' already there (physical path updated)" } else { $r = Invoke-AppCmd @('add', 'site', "/name:$siteName", "/bindings:$binding", "/physicalPath:$appDir") if ($r.code -ne 0) { Fail "Cannot create the IIS site '$siteName' on port ${Port}: $($r.out)" 4 } $null = Invoke-AppCmd @('set', 'app', "/app.name:$siteName/", "/applicationPool:$poolName") Write-Ok "site '$siteName' created on port $Port" } # The pool identity must read the app and write settings/logs next to it. Grant-FolderAccess $poolAccount $appDir # ...and needs a SQL login, or the first-run wizard cannot create databases. # Solo per SQL Server: l'accesso avviene con l'identita' di Windows del pool. Su MySQL # ci si autentica con utente e password nella stringa di connessione, e non c'e' nessun # account di Windows da abilitare. if ($Dbms -eq 'mssql') { $null = Grant-SqlLogin $sqlDs $poolAccount } $null = Invoke-AppCmd @('start', 'site', "/site.name:$siteName") } if ($NoStart) { # Anche chi chiede -NoStart deve poter lavorare subito: senza queste righe l'installazione # finiva con "Done." e la stringa di connessione - l'unica cosa che il wizard chiede al # primo avvio - non veniva stampata da nessuna parte. Write-Host "" if ($Kestrel) { Write-Host "Done (not started). Start it with: $startCmd" -ForegroundColor White } else { Write-Host "Done. IIS site '$siteName' is configured on port ${Port}: open http://localhost:$Port/ when you want it." -ForegroundColor White } Write-Host "" Write-Host "At the first start the wizard asks for this connection string ($authNote):" -ForegroundColor White Write-Host " $dataConnection" -ForegroundColor Yellow if ($secretNote) { Write-Host " $secretNote" -ForegroundColor Gray } if ($WithTutorial) { Write-Host " or choose setup mode 'Tutorial WideWorldImporters' to provision the demo database." -ForegroundColor Gray } Write-Host "Then pick the admin password. Without a license the app runs in Trial mode (20 records per query)." -ForegroundColor Gray Write-Host "Docs: $BaseUrl/docs/getting-started" -ForegroundColor Gray exit 0 } # 7. Wait for the app to answer ------------------------------------------------------------------ Write-Step "Starting the backend on http://localhost:$Port" $health = "http://localhost:$Port/api/Meta/FirstRunStatus" $alreadyUp = $false try { $null = Invoke-WebRequest -Uri $health -UseBasicParsing -TimeoutSec 5; $alreadyUp = $true } catch { } if ($alreadyUp) { Write-Ok "Backend already answering on port $Port" } else { if ($Kestrel) { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', "`"$startCmd`"") -WorkingDirectory $appDir | Out-Null } else { # IIS starts the worker on the first request: this is that request. try { $null = Invoke-WebRequest -Uri "http://localhost:$Port/" -UseBasicParsing -TimeoutSec 30 } catch { } } $deadline = (Get-Date).AddSeconds(180); $up = $false while (-not $up -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 try { $null = Invoke-WebRequest -Uri $health -UseBasicParsing -TimeoutSec 5; $up = $true } catch { } } if (-not $up) { if ($Kestrel) { Fail "The backend did not answer on $health within 180 s. Look at the console window that was opened (and $appDir\logs)." 3 } Fail "The IIS site did not answer on $health within 180 s. Check the Windows event log (Application) and $appDir\logs; 'iisreset' after a fresh Hosting Bundle install often fixes it. You can also rerun with -Kestrel." 3 } Write-Ok "Backend up" } Start-Process "http://localhost:$Port/" Write-Host "" Write-Host "WUIC is running: http://localhost:$Port/" -ForegroundColor Green Write-Host "" Write-Host "In the first-run wizard paste this connection string ($authNote):" -ForegroundColor White Write-Host " $dataConnection" -ForegroundColor Yellow if ($secretNote) { Write-Host " $secretNote" -ForegroundColor Gray } if ($WithTutorial) { Write-Host " or choose setup mode 'Tutorial WideWorldImporters' to provision the demo database." -ForegroundColor Gray } Write-Host "Then pick the admin password. Without a license the app runs in Trial mode (20 records per query)." -ForegroundColor Gray Write-Host "" if ($Kestrel) { Write-Host "Restart later: $startCmd" -ForegroundColor Gray } else { Write-Host "Hosted by IIS: site '$siteName', application pool '$poolName', folder $appDir" -ForegroundColor Gray Write-Host "Restart later: iisreset, or Manage Website > Restart in IIS Manager" -ForegroundColor Gray } Write-Host "Docs: $BaseUrl/docs/getting-started" -ForegroundColor Gray exit 0