@noeva/analytics-core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ # Installa o aggiorna il worker degli snapshot di {{progetto}} (NOEVA Analytics, ADR 0004),
2
+ # passo per passo, e alla fine dice com'e' andata.
3
+ #
4
+ # powershell -ExecutionPolicy Bypass -File .\installa.ps1 installa o aggiorna
5
+ # powershell -ExecutionPolicy Bypass -File .\installa.ps1 -SoloControllo controlla e basta (dopo un riavvio)
6
+ #
7
+ # Si lancia dalla cartella estratta dallo zip, ENTRANDO IN WINDOWS COME L'UTENTE CHE FARA'
8
+ # GIRARE IL WORKER: le chiavi vanno nelle sue variabili d'ambiente. La copia in
9
+ # C:\NOEVA\snapshot la fa lo script; se c'e' gia' e' un aggiornamento (la cartella vecchia si
10
+ # mette da parte, lavoro\ e le chiavi restano). Alla fine il primo giro parte subito: lo
11
+ # scheduler di NOEVA lo mette in coda e il worker lo esegue qui davanti, poi l'attivita'
12
+ # pianificata prende il posto (ogni {{minuti}} minuti, ascolta --una-volta).
13
+ # Nessun segreto finisce a schermo o nel file dell'esito.
14
+
15
+ param([switch]$SoloControllo)
16
+
17
+ $ErrorActionPreference = 'Stop'
18
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { }
19
+
20
+ $qui = Split-Path -Parent $MyInvocation.MyCommand.Path
21
+ $progetto = '{{progetto}}'
22
+ $utenteDb = '{{utente}}'
23
+ $varPassword = '{{password_env}}'
24
+ $dbHost = '{{host}}'
25
+ $dbPorta = {{porta}}
26
+ $minuti = {{minuti}}
27
+ $pipeline = "analytics-snapshot-$progetto"
28
+ $attivita = "NOEVA snapshot $progetto"
29
+ $destinazione = 'C:\NOEVA\snapshot'
30
+ $exe = Join-Path $destinazione 'noeva-snapshot\noeva-snapshot.exe'
31
+ $pacchetto = Join-Path $destinazione "pacchetti\$progetto"
32
+ $modo = 'nuova installazione'
33
+ $esiti = New-Object System.Collections.ArrayList
34
+ $conf = Get-Content (Join-Path $qui 'worker.json') -Raw -Encoding UTF8 | ConvertFrom-Json
35
+ $api = $conf.api.TrimEnd('/')
36
+ $contenitore = $conf.contenitore.TrimEnd('/')
37
+ $workspace = $conf.workspace
38
+
39
+ function Passo($titolo) {
40
+ Write-Host ''
41
+ Write-Host "== $titolo" -ForegroundColor Cyan
42
+ }
43
+
44
+ function Esito($passo, $stato, $nota) {
45
+ [void]$esiti.Add([pscustomobject]@{ Passo = $passo; Stato = $stato; Nota = $nota })
46
+ $colore = @{ 'ok' = 'Green'; 'avviso' = 'Yellow'; 'NO' = 'Red'; 'saltato' = 'DarkGray' }[$stato]
47
+ Write-Host (' {0,-7} {1}' -f $stato, $nota) -ForegroundColor $colore
48
+ }
49
+
50
+ function Chiedi($domanda, [bool]$predefinito = $true) {
51
+ $suggerimento = if ($predefinito) { '(S/n)' } else { '(s/N)' }
52
+ $r = Read-Host " $domanda $suggerimento"
53
+ if (-not $r) { return $predefinito }
54
+ return $r.Trim().ToLower().StartsWith('s')
55
+ }
56
+
57
+ function ChiediSegreto($domanda) {
58
+ $sicura = Read-Host " $domanda" -AsSecureString
59
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sicura)
60
+ try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
61
+ finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
62
+ }
63
+
64
+ function PortaAperta($h, $p) {
65
+ $c = New-Object Net.Sockets.TcpClient
66
+ try { return $c.BeginConnect($h, $p, $null, $null).AsyncWaitHandle.WaitOne(5000) -and $c.Connected }
67
+ catch { return $false }
68
+ finally { $c.Close() }
69
+ }
70
+
71
+ function Esegui($comando, [string[]]$argomenti) {
72
+ $prima = $ErrorActionPreference
73
+ $ErrorActionPreference = 'Continue'
74
+ try {
75
+ $testo = (& $comando @argomenti 2>&1 | ForEach-Object { "$_" }) -join "`n"
76
+ return [pscustomobject]@{ Codice = $LASTEXITCODE; Testo = $testo }
77
+ } finally { $ErrorActionPreference = $prima }
78
+ }
79
+
80
+ # Come Esegui, ma le righe si vedono mentre arrivano (la verifica e il giro durano minuti).
81
+ function EseguiDalVivo($comando, [string[]]$argomenti) {
82
+ $prima = $ErrorActionPreference
83
+ $ErrorActionPreference = 'Continue'
84
+ try {
85
+ $righe = New-Object System.Collections.ArrayList
86
+ & $comando @argomenti 2>&1 | ForEach-Object { $r = "$_"; [void]$righe.Add($r); Write-Host " $r" }
87
+ return [pscustomobject]@{ Codice = $LASTEXITCODE; Testo = ($righe -join "`n") }
88
+ } finally { $ErrorActionPreference = $prima }
89
+ }
90
+
91
+ # Una chiamata all'API NOEVA con la chiave del worker. Cloudflare blocca lo user agent di
92
+ # PowerShell come quello di Python: ci si presenta come noeva-snapshot.
93
+ function ApiNoeva($metodo, $percorso, $corpo) {
94
+ $par = @{
95
+ Method = $metodo; Uri = "$api$percorso"; UseBasicParsing = $true; TimeoutSec = 60
96
+ UserAgent = 'noeva-snapshot (NOEVA Analytics)'
97
+ Headers = @{ Authorization = "Bearer $env:NOEVA_API_KEY"; 'x-noeva-workspace' = $workspace }
98
+ }
99
+ if ($corpo) { $par.Body = ($corpo | ConvertTo-Json -Compress); $par.ContentType = 'application/json' }
100
+ try {
101
+ $r = Invoke-WebRequest @par
102
+ return [pscustomobject]@{ Codice = [int]$r.StatusCode; Corpo = $(if ($r.Content) { $r.Content | ConvertFrom-Json } else { $null }); Errore = '' }
103
+ } catch {
104
+ $codice = 0
105
+ if ($_.Exception.Response) { $codice = [int]$_.Exception.Response.StatusCode }
106
+ $messaggio = if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { $_.Exception.Message }
107
+ return [pscustomobject]@{ Codice = $codice; Corpo = $null; Errore = $messaggio.Substring(0, [Math]::Min(200, $messaggio.Length)) }
108
+ }
109
+ }
110
+
111
+ function Fine {
112
+ $no = @($esiti | Where-Object { $_.Stato -eq 'NO' }).Count
113
+ $avvisi = @($esiti | Where-Object { $_.Stato -eq 'avviso' }).Count
114
+ $esitoGenerale = if ($no) { "NON COMPLETATA: $no passi da sistemare" } elseif ($avvisi) { "COMPLETATA con $avvisi avvisi" } else { 'COMPLETATA' }
115
+ $quando = Get-Date
116
+ $runner = ''
117
+ $quale = if (Test-Path $exe) { $exe } else { Join-Path $qui 'noeva-snapshot\noeva-snapshot.exe' }
118
+ if (Test-Path $quale) { try { $runner = ((Esegui $quale @('versione')).Testo | ConvertFrom-Json).runner } catch { } }
119
+ $righe = @(
120
+ "worker degli snapshot di $progetto (runner $runner) · $modo · $esitoGenerale",
121
+ "macchina $env:COMPUTERNAME · utente Windows $env:USERDOMAIN\$env:USERNAME · $($quando.ToString('dd/MM/yyyy HH:mm'))",
122
+ ''
123
+ ) + ($esiti | ForEach-Object { '{0,-7} {1,-20} {2}' -f $_.Stato, $_.Passo, $_.Nota })
124
+ Write-Host ''
125
+ Write-Host '==================== RIEPILOGO ====================' -ForegroundColor Cyan
126
+ $colore = if ($no) { 'Red' } elseif ($avvisi) { 'Yellow' } else { 'Green' }
127
+ Write-Host $righe[0] -ForegroundColor $colore
128
+ $righe | Select-Object -Skip 1 | ForEach-Object { Write-Host $_ }
129
+ $cartellaEsito = if (Test-Path $destinazione) { $destinazione } else { $qui }
130
+ $file = Join-Path $cartellaEsito ("esito-installa-{0}.txt" -f $quando.ToString('yyyyMMdd-HHmm'))
131
+ try {
132
+ $righe | Set-Content -Path $file -Encoding UTF8
133
+ Write-Host ''
134
+ Write-Host "Esito salvato in $file (senza chiavi ne' password): da mandare a chi segue l'installazione."
135
+ } catch { Write-Host "Non riesco a salvare l'esito in $file" -ForegroundColor Yellow }
136
+ if ($no) { exit 1 } else { exit 0 }
137
+ }
138
+
139
+ function Ferma($passo, $nota) {
140
+ Esito $passo 'NO' $nota
141
+ Write-Host ' Mi fermo qui: sistemato questo, rilancia lo script.' -ForegroundColor Red
142
+ Fine
143
+ }
144
+
145
+ Write-Host "NOEVA Analytics · worker degli snapshot di $progetto" -ForegroundColor Cyan
146
+ Write-Host "macchina $env:COMPUTERNAME · utente Windows $env:USERDOMAIN\$env:USERNAME"
147
+ if ($SoloControllo) { $modo = 'solo controllo'; Write-Host 'Solo controllo: non cambio niente.' }
148
+
149
+ # 0. L'utente ----------------------------------------------------------------------
150
+ if (-not $SoloControllo) {
151
+ Passo "0. L'utente Windows del worker"
152
+ Write-Host " Il worker girera' come $env:USERDOMAIN\$env:USERNAME, con le sue variabili d'ambiente."
153
+ Write-Host ' Su un server: un account di servizio dedicato, non amministratore, non una persona.'
154
+ if (Chiedi "E' l'utente giusto?") { Esito 'utente' 'ok' "$env:USERDOMAIN\$env:USERNAME" }
155
+ else { Ferma 'utente' 'entra in Windows come l''utente del worker e rilancia' }
156
+ }
157
+
158
+ # 1. La rete -------------------------------------------------------------------------
159
+ Passo '1. La rete: il database e NOEVA, solo in uscita'
160
+ if (PortaAperta $dbHost $dbPorta) { Esito 'rete database' 'ok' "$dbHost`:$dbPorta" }
161
+ else { Ferma 'rete database' "$dbHost`:$dbPorta non risponde: rete o VPN, per l'IT" }
162
+ foreach ($u in @($api, $contenitore)) {
163
+ $h = ([Uri]$u).Host
164
+ if (PortaAperta $h 443) { Esito 'rete NOEVA' 'ok' "$h`:443" }
165
+ else { Ferma 'rete NOEVA' "$h`:443 non risponde: serve l'uscita HTTPS (proxy?), per l'IT" }
166
+ }
167
+
168
+ # 2. Il driver ODBC --------------------------------------------------------------------
169
+ Passo '2. Il driver ODBC per SQL Server'
170
+ $driver = @(Get-OdbcDriver -Platform 64-bit -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^ODBC Driver (18|17) for SQL Server$' } | ForEach-Object { $_.Name })
171
+ if ($driver -contains 'ODBC Driver 18 for SQL Server') { Esito 'driver' 'ok' 'ODBC Driver 18 for SQL Server' }
172
+ elseif ($driver -contains 'ODBC Driver 17 for SQL Server') {
173
+ if (-not $SoloControllo) { [Environment]::SetEnvironmentVariable('NOEVA_MSSQL_DRIVER', 'ODBC Driver 17 for SQL Server', 'User') }
174
+ $env:NOEVA_MSSQL_DRIVER = 'ODBC Driver 17 for SQL Server'
175
+ Esito 'driver' 'avviso' 'c''e'' il 17, non il 18: va bene, impostato NOEVA_MSSQL_DRIVER'
176
+ } else {
177
+ Ferma 'driver' 'manca: ODBC Driver 18 for SQL Server x64 da https://aka.ms/downloadmsodbcsql (serve un amministratore)'
178
+ }
179
+ $driverUtente = [Environment]::GetEnvironmentVariable('NOEVA_MSSQL_DRIVER', 'User')
180
+ if ($driverUtente) { $env:NOEVA_MSSQL_DRIVER = $driverUtente }
181
+
182
+ # 3. La cartella -------------------------------------------------------------------------
183
+ Passo "3. La cartella $destinazione"
184
+ $giaInstallata = [bool](Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue)
185
+ if ($SoloControllo) {
186
+ if (Test-Path $exe) { Esito 'cartella' 'ok' $destinazione } else { Ferma 'cartella' "$exe non c'e'" }
187
+ } elseif ($qui.TrimEnd('\') -ieq $destinazione) {
188
+ $modo = 'sul posto'
189
+ Esito 'cartella' 'ok' "lo script gira gia' da $destinazione"
190
+ } else {
191
+ if (Test-Path $destinazione) {
192
+ $modo = 'aggiornamento'
193
+ # Durante la copia l'attivita' non deve partire: si riaccende al passo 7.
194
+ if ($giaInstallata) { Disable-ScheduledTask -TaskName $attivita | Out-Null }
195
+ $inCorso = @(Get-Process -Name 'noeva-snapshot' -ErrorAction SilentlyContinue)
196
+ if ($inCorso.Count) {
197
+ Write-Host ' Un giro e'' in corso: aspetto che finisca (al massimo 10 minuti).'
198
+ $inCorso | Wait-Process -Timeout 600 -ErrorAction SilentlyContinue
199
+ }
200
+ $vecchia = "$destinazione-prima-$((Get-Date).ToString('yyyyMMdd-HHmm'))"
201
+ try { Rename-Item $destinazione (Split-Path -Leaf $vecchia) }
202
+ catch { Ferma 'cartella' "non riesco a rinominare $destinazione (in uso?): $($_.Exception.Message)" }
203
+ Copy-Item $qui $destinazione -Recurse
204
+ # lavoro\ (gli ultimi giri e ascolta.log) resta: serve a capire i giri passati.
205
+ if (Test-Path (Join-Path $vecchia 'lavoro')) { Copy-Item (Join-Path $vecchia 'lavoro') (Join-Path $destinazione 'lavoro') -Recurse -Force }
206
+ Esito 'cartella' 'ok' "vecchia messa da parte in $vecchia, lavoro\ riportato"
207
+ } else {
208
+ New-Item -ItemType Directory -Force (Split-Path $destinazione) | Out-Null
209
+ Copy-Item $qui $destinazione -Recurse
210
+ Esito 'cartella' 'ok' "copiata in $destinazione"
211
+ }
212
+ Get-ChildItem $destinazione -Recurse | Unblock-File
213
+ if (-not (Test-Path $exe)) { Ferma 'cartella' "$exe non c'e' dopo la copia" }
214
+ }
215
+ New-Item -ItemType Directory -Force (Join-Path $destinazione 'lavoro') | Out-Null
216
+ $v = Esegui $exe @('versione')
217
+ if ($v.Codice -eq 0) { Esito 'eseguibile' 'ok' $v.Testo.Trim() } else { Ferma 'eseguibile' "non parte: $($v.Testo.Trim())" }
218
+
219
+ # 4. La chiave del worker -----------------------------------------------------------------
220
+ Passo '4. La chiave NOEVA del worker'
221
+ $chiave = [Environment]::GetEnvironmentVariable('NOEVA_API_KEY', 'User')
222
+ if ($chiave -and -not $SoloControllo -and -not (Chiedi "La chiave e' gia' salvata per $env:USERNAME. La tengo?")) { $chiave = '' }
223
+ if (-not $chiave) {
224
+ if ($SoloControllo) { Ferma 'chiave' 'manca NOEVA_API_KEY: rilancia senza -SoloControllo' }
225
+ for ($i = 0; $i -lt 3; $i++) {
226
+ $chiave = ChiediSegreto "Chiave NOEVA dell'utente di servizio (noeva_sk_..., non si vede mentre scrivi)"
227
+ if ($chiave.StartsWith('noeva_sk_')) { break }
228
+ Write-Host ' Non sembra una chiave NOEVA (comincia con noeva_sk_).' -ForegroundColor Yellow
229
+ $chiave = ''
230
+ }
231
+ if (-not $chiave) { Ferma 'chiave' 'nessuna chiave valida inserita' }
232
+ [Environment]::SetEnvironmentVariable('NOEVA_API_KEY', $chiave, 'User')
233
+ }
234
+ $env:NOEVA_API_KEY = $chiave
235
+ $chiave = $null
236
+ # La prova della chiave: un biglietto per una versione di prova. Dice in una volta se la chiave
237
+ # vale, se l'utente e' membro del workspace e se il workspace ha il modulo. Non scrive niente.
238
+ $b = ApiNoeva 'POST' '/api/analytics/biglietto-dati' @{ progetto = $progetto; versione = 'prova-installazione'; da = $env:COMPUTERNAME }
239
+ switch ($b.Codice) {
240
+ 200 { Esito 'chiave' 'ok' "valida, l'utente e' membro del workspace e il modulo e' attivo" }
241
+ 401 { Ferma 'chiave' 'non valida o revocata: rilancia e reinseriscila' }
242
+ 403 { Ferma 'chiave' "rifiutata: $($b.Errore) (l'utente e' membro del workspace? il modulo e' abilitato?)" }
243
+ 503 { Ferma 'chiave' "l'API non rilascia biglietti (manca ANALYTICS_SEGRETO_DATI): e' da NOEVA, non da qui" }
244
+ default { Ferma 'chiave' "risposta inattesa dall'API ($($b.Codice)): $($b.Errore)" }
245
+ }
246
+
247
+ # 5. La password e la verifica ----------------------------------------------------------------
248
+ Passo "5. La password di $utenteDb e la verifica dal vivo"
249
+ $password = [Environment]::GetEnvironmentVariable($varPassword, 'User')
250
+ if ($password -and -not $SoloControllo -and -not (Chiedi "La password e' gia' salvata per $env:USERNAME. La tengo?")) { $password = '' }
251
+ if (-not $password) {
252
+ if ($SoloControllo) { Ferma 'password' "manca $varPassword`: rilancia senza -SoloControllo" }
253
+ for ($i = 0; $i -lt 3 -and -not $password; $i++) { $password = ChiediSegreto "Password di $utenteDb (non si vede mentre scrivi)" }
254
+ if (-not $password) { Ferma 'password' 'nessuna password inserita' }
255
+ [Environment]::SetEnvironmentVariable($varPassword, $password, 'User')
256
+ }
257
+ Set-Item "env:$varPassword" $password
258
+ $password = $null
259
+ $v = EseguiDalVivo $exe @('verifica', '--pacchetto', $pacchetto, '--connessione', (Join-Path $pacchetto 'connessione.json'))
260
+ $avvisiVerifica = @($v.Testo -split "`n" | Where-Object { $_ -match '^\s*avviso\s' }).Count
261
+ if ($v.Codice -eq 0 -and $avvisiVerifica) { Esito 'verifica' 'avviso' "i controlli tornano, $avvisiVerifica avvisi entro la tolleranza (dati che l'ERP riscrive)" }
262
+ elseif ($v.Codice -eq 0) { Esito 'verifica' 'ok' 'connessione e cifre di controllo tornano' }
263
+ else {
264
+ $primoNo = ($v.Testo -split "`n" | Where-Object { $_ -match '^\s*NO\s' } | Select-Object -First 1)
265
+ Ferma 'verifica' $(if ($primoNo) { $primoNo.Trim() } else { "uscita $($v.Codice)" })
266
+ }
267
+
268
+ # 6. Il primo giro ------------------------------------------------------------------------------
269
+ if ($SoloControllo) {
270
+ $log = Join-Path $destinazione 'lavoro\ascolta.log'
271
+ if (Test-Path $log) {
272
+ $ultima = Get-Content $log -Tail 40 | Where-Object { $_ -match 'pubblicata|FALLITO' } | Select-Object -Last 1
273
+ Esito 'ultimo giro' $(if ($ultima -match 'FALLITO') { 'NO' } elseif ($ultima) { 'ok' } else { 'avviso' }) $(if ($ultima) { $ultima.Trim() } else { 'nessun giro ancora in ascolta.log' })
274
+ } else { Esito 'ultimo giro' 'avviso' 'ascolta.log non c''e'' ancora' }
275
+ } else {
276
+ Passo '6. Il primo giro, adesso'
277
+ if (Chiedi 'Faccio partire adesso il primo giro (circa 4 minuti)?') {
278
+ $jobs = ApiNoeva 'GET' '/api/scheduled-jobs' $null
279
+ $job = @($jobs.Corpo.data | Where-Object { $_.process_function -eq $pipeline }) | Select-Object -First 1
280
+ if (-not $job) {
281
+ Esito 'schedulazione' 'NO' "nessuna schedulazione '$pipeline' nel workspace: va creata nel pannello NOEVA (tipo ETL)"
282
+ } else {
283
+ Esito 'schedulazione' $(if ($job.enabled) { 'ok' } else { 'avviso' }) ("'{0}', {1} {2}{3}" -f $job.name, $job.cron_expression, $job.timezone, $(if ($job.enabled) { '' } else { ', DISATTIVATA: riattivala dal pannello' }))
284
+ $t = ApiNoeva 'POST' "/api/scheduled-jobs/$($job.id)/trigger" $null
285
+ if ($t.Codice -ne 200) { Esito 'primo giro' 'NO' "non riesco a farlo partire ($($t.Codice)): $($t.Errore)" }
286
+ else {
287
+ Write-Host ' In coda. Lo eseguo qui (i giri arretrati in coda, se ce ne sono, passano tutti):'
288
+ $g = EseguiDalVivo $exe @('ascolta', '--config', (Join-Path $destinazione 'worker.json'), '--una-volta')
289
+ Add-Content -Path (Join-Path $destinazione 'lavoro\ascolta.log') -Value $g.Testo -Encoding UTF8
290
+ $pubblicate = @([regex]::Matches($g.Testo, ': pubblicata (\S+) in') | ForEach-Object { $_.Groups[1].Value })
291
+ $falliti = @($g.Testo -split "`n" | Where-Object { $_ -match 'FALLITO' })
292
+ if ($falliti.Count) { Esito 'primo giro' 'NO' $falliti[0].Trim() }
293
+ elseif ($pubblicate.Count) { Esito 'primo giro' 'ok' "pubblicata la versione $($pubblicate[-1]) ($($pubblicate.Count) giri)" }
294
+ else { Esito 'primo giro' 'avviso' 'coda vuota: il giro lo prendera'' l''attivita'' pianificata, guarda ascolta.log' }
295
+ }
296
+ }
297
+ } else { Esito 'primo giro' 'saltato' 'lo fara'' l''attivita'' pianificata alla prossima schedulazione' }
298
+ }
299
+
300
+ # 7. L'attivita' pianificata --------------------------------------------------------------------
301
+ Passo "7. L'attivita' pianificata '$attivita'"
302
+ if ($SoloControllo) {
303
+ $t = Get-ScheduledTask -TaskName $attivita -ErrorAction SilentlyContinue
304
+ if (-not $t) { Esito 'attivita''' 'NO' 'non installata: rilancia senza -SoloControllo' }
305
+ else {
306
+ $info = Get-ScheduledTaskInfo -TaskName $attivita
307
+ $stato = if ($t.State -eq 'Disabled') { 'NO' } elseif ($info.LastTaskResult -in 0, 267009, 267011) { 'ok' } else { 'avviso' }
308
+ Esito 'attivita''' $stato ("{0}, ultimo avvio {1}, risultato {2}" -f $t.State, $info.LastRunTime, $info.LastTaskResult)
309
+ }
310
+ } else {
311
+ Write-Host " Ogni $minuti minuti lancia 'ascolta --una-volta' come $env:USERNAME, anche senza sessione aperta:"
312
+ Write-Host ' per questo Windows chiede la password di questo utente (quella di Windows, non del database).'
313
+ $credenziali = Get-Credential -UserName "$env:USERDOMAIN\$env:USERNAME" -Message "Password Windows di $env:USERNAME"
314
+ if (-not $credenziali) { Ferma 'attivita''' 'nessuna password Windows: senza, l''attivita'' non gira a sessione chiusa' }
315
+ $log = Join-Path $destinazione 'lavoro\ascolta.log'
316
+ $azione = New-ScheduledTaskAction -Execute 'cmd.exe' -WorkingDirectory $destinazione `
317
+ -Argument "/c `"`"$exe`" ascolta --config `"$(Join-Path $destinazione 'worker.json')`" --una-volta >> `"$log`" 2>&1`""
318
+ $quando = New-ScheduledTaskTrigger -Once -At (Get-Date).Date -RepetitionInterval (New-TimeSpan -Minutes $minuti)
319
+ $regole = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
320
+ -StartWhenAvailable -DontStopIfGoingOnBatteries -AllowStartIfOnBatteries
321
+ try {
322
+ Register-ScheduledTask -TaskName $attivita -Action $azione -Trigger $quando -Settings $regole `
323
+ -User $credenziali.UserName -Password $credenziali.GetNetworkCredential().Password -RunLevel Limited -Force | Out-Null
324
+ Enable-ScheduledTask -TaskName $attivita | Out-Null
325
+ Esito 'attivita''' 'ok' "ogni $minuti minuti come $($credenziali.UserName); registro in $log"
326
+ } catch {
327
+ Esito 'attivita''' 'NO' "Windows l'ha rifiutata (password giusta?): $($_.Exception.Message)"
328
+ }
329
+ $credenziali = $null
330
+ }
331
+
332
+ # 8. La dashboard ------------------------------------------------------------------------------
333
+ if (-not $SoloControllo) {
334
+ Passo '8. La dashboard'
335
+ Write-Host " Apri $contenitore/$progetto/ (login NOEVA) e guarda la data dei dati."
336
+ if (Chiedi 'La data e'' quella di oggi?') { Esito 'dashboard' 'ok' 'dati di oggi' }
337
+ else { Esito 'dashboard' 'avviso' 'da guardare con chi segue l''installazione (la pagina si ricarica?)' }
338
+ }
339
+
340
+ Fine
package/core.js CHANGED
@@ -50,7 +50,12 @@ export function leggiConfig(radice) {
50
50
  if (!Array.isArray(nonGestiti) || nonGestiti.some((p) => typeof p !== 'string')) {
51
51
  throw new ErroreConfig('analytics.json: `non_gestiti` deve essere un elenco di percorsi');
52
52
  }
53
- return { progetto: c.progetto, nonGestiti };
53
+ // Il workspace NOEVA che possiede il progetto: lo nomina il biglietto con cui
54
+ // `analytics pubblica` scrive le pagine. Non e' un segreto, e' un id.
55
+ if (c.workspace !== undefined && typeof c.workspace !== 'string') {
56
+ throw new ErroreConfig('analytics.json: `workspace` deve essere l\'id del workspace NOEVA');
57
+ }
58
+ return { progetto: c.progetto, nonGestiti, ...(c.workspace ? { workspace: c.workspace } : {}) };
54
59
  }
55
60
 
56
61
  // --- file gestiti ---------------------------------------------------------
@@ -78,7 +83,9 @@ export function fileGestiti(config) {
78
83
  for (const f of readdirSync(join(CONTENUTO, 'assets')).sort()) {
79
84
  voci.push({ percorso: `apps/assets/${f}`, sorgente: join(CONTENUTO, 'assets', f) });
80
85
  }
81
- voci.push({ percorso: '.github/workflows/ci.yml', sorgente: join(MODELLO, 'github', 'workflows', 'ci.yml') });
86
+ for (const w of ['ci.yml', 'rilascio-windows.yml']) {
87
+ voci.push({ percorso: `.github/workflows/${w}`, sorgente: join(MODELLO, 'github', 'workflows', w) });
88
+ }
82
89
  for (const nome of readdirSync(join(KIT, 'skills')).sort()) {
83
90
  const base = join(KIT, 'skills', nome);
84
91
  for (const f of fileSotto(base).sort()) {
@@ -269,6 +276,15 @@ export function verifica(radice, config) {
269
276
 
270
277
  // L'ultima versione pubblicata, o null se il registry non risponde o il pacchetto
271
278
  // non c'e': il controllo resta in sola lettura e non fallisce per la rete.
279
+ /** a e' una versione piu' nuova di b? (x.y.z; un prerilascio o un formato strano non lo e') */
280
+ export function piuNuova(a, b) {
281
+ const n = (v) => (/^\d+\.\d+\.\d+$/.test(v || '') ? v.split('.').map(Number) : null);
282
+ const [x, y] = [n(a), n(b)];
283
+ if (!x || !y) return false;
284
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
285
+ return false;
286
+ }
287
+
272
288
  export async function ultimaVersione(fetchImpl = fetch) {
273
289
  try {
274
290
  const r = await fetchImpl('https://registry.npmjs.org/@noeva%2fanalytics-core', { signal: AbortSignal.timeout(5000) });
@@ -1,7 +1,19 @@
1
- # La verifica del progetto: `analytics ci` del pacchetto @noeva/analytics-core.
2
- # Quali controlli girano lo decide la versione del pacchetto, non questo file:
3
- # qui ci sono solo l'installazione e il comando (ADR 0005 di NOEVA Analytics).
1
+ # La verifica del progetto e, su main, la pubblicazione (ADR 0005 di NOEVA Analytics).
2
+ # Quali controlli girano lo decide la versione del pacchetto @noeva/analytics-core,
3
+ # non questo file: qui ci sono solo l'installazione e i comandi.
4
4
  # File gestito: lo riscrive `analytics upgrade`, non si modifica a mano.
5
+ #
6
+ # `pubblica` porta online, a ogni push su main che passa la verifica, le pagine
7
+ # (apps/) e il modello compilato del commit: su R2, attraverso il contenitore, con
8
+ # un biglietto che l'API NOEVA rilascia alla chiave di un membro del workspace del
9
+ # progetto. Si accende con due impostazioni del repository:
10
+ #
11
+ # secret NOEVA_API_KEY la chiave API NOEVA di un utente del workspace
12
+ # variable ANALYTICS_PUBBLICA "si" quando il contenitore serve questo progetto da R2
13
+ # variable ANALYTICS_URL facoltativa, default https://analytics.noeva.ai
14
+ #
15
+ # I dati no: lo snapshot si genera dove c'e' il database (aggiorna.sh --pubblica
16
+ # sul Mac, o il worker dal cliente) e si pubblica da li'.
5
17
 
6
18
  name: ci
7
19
 
@@ -25,3 +37,41 @@ jobs:
25
37
  node-version: '24'
26
38
  - run: npm ci
27
39
  - run: npx analytics ci
40
+
41
+ pubblica:
42
+ needs: verifica
43
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && vars.ANALYTICS_PUBBLICA == 'si'
44
+ runs-on: ubuntu-latest
45
+ # Una pubblicazione alla volta, e quella in corso non si interrompe: la
46
+ # successiva aspetta e pubblica il main piu' recente.
47
+ concurrency:
48
+ group: pubblica
49
+ cancel-in-progress: false
50
+ env:
51
+ NOEVA_API_KEY: ${{ secrets.NOEVA_API_KEY }}
52
+ URL: ${{ vars.ANALYTICS_URL || 'https://analytics.noeva.ai' }}
53
+ steps:
54
+ - uses: actions/checkout@v4
55
+ - uses: actions/setup-node@v4
56
+ with:
57
+ node-version: '24'
58
+ - run: npm ci
59
+ - name: la chiave API NOEVA c'e'
60
+ run: |
61
+ if [ -z "$NOEVA_API_KEY" ]; then
62
+ echo "::error::manca il secret NOEVA_API_KEY: la chiave di un utente del workspace del progetto"
63
+ exit 1
64
+ fi
65
+ - name: le pagine
66
+ run: npx analytics pubblica --url "$URL"
67
+ # Il modello si compila con wren, che e' Python: le versioni sono quelle del
68
+ # servizio locale (lib/mcp-locale/ambiente del pacchetto).
69
+ - uses: actions/setup-python@v5
70
+ if: hashFiles('wren_project.yml') != ''
71
+ with:
72
+ python-version: '3.11'
73
+ - name: il modello
74
+ if: hashFiles('wren_project.yml') != ''
75
+ run: |
76
+ pip install --quiet "wrenai==0.15.0" "wren-core-py==0.8.0" "pyyaml>=6"
77
+ PYTHON=python npx analytics modello --url "$URL"
@@ -0,0 +1,207 @@
1
+ # I due eseguibili Windows del progetto, costruiti e provati su un runner Windows
2
+ # (PyInstaller non fa cross-compilazione), con le ricette del pacchetto
3
+ # @noeva/analytics-core (ADR 0005 di NOEVA Analytics):
4
+ #
5
+ # mcp la cartella per Claude Code sui PC: noeva-<id>.exe, il servizio MCP,
6
+ # con la configurazione di consegna.yml. Il modello lo scarica da se'
7
+ # dall'ultima versione pubblicata (ci.yml, job pubblica); quello nella
8
+ # cartella e' l'ultimo ripiego.
9
+ # snapshot l'eseguibile del worker degli snapshot (noeva-snapshot.exe) e il
10
+ # pacchetto del progetto, per il server del cliente (ADR 0004).
11
+ #
12
+ # Solo manuale. Nessuna credenziale: le cartelle non ne contengono, e il database
13
+ # del cliente da qui non si raggiunge. Si prova fin dove si arriva senza: il driver
14
+ # ODBC 18, il login che fallisce sulla rete, installa.ps1 con PowerShell 5.1.
15
+ #
16
+ # Le cartelle escono come artefatto (7 giorni). Con `rilascia` diventano una
17
+ # versione permanente di questo repository: noeva-<id>-v<versione> e
18
+ # noeva-snapshot-<id>-v<versione>. Una versione non si riscrive.
19
+ # File gestito: lo riscrive `analytics upgrade`, non si modifica a mano.
20
+
21
+ name: rilascio-windows
22
+
23
+ on:
24
+ workflow_dispatch:
25
+ inputs:
26
+ cosa:
27
+ description: "mcp = la cartella per Claude Code sui PC; snapshot = l'eseguibile del worker (ADR 0004)"
28
+ type: choice
29
+ options: [mcp, snapshot]
30
+ default: mcp
31
+ versione:
32
+ description: "La versione (es. 2026.11.6); vuota = la data di oggi"
33
+ required: false
34
+ default: ""
35
+ rilascia:
36
+ description: "Pubblica la versione come release permanente di questo repository"
37
+ type: boolean
38
+ default: false
39
+
40
+ permissions:
41
+ contents: write
42
+
43
+ jobs:
44
+ windows:
45
+ runs-on: windows-latest
46
+ timeout-minutes: 40
47
+ env:
48
+ # Il builder e il server stampano accenti e "·": la console del runner no.
49
+ PYTHONUTF8: "1"
50
+ PYTHONIOENCODING: utf-8
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-node@v4
54
+ with:
55
+ node-version: '24'
56
+ - run: npm ci
57
+ - uses: astral-sh/setup-uv@v6
58
+
59
+ - name: Il progetto e le ricette del pacchetto
60
+ id: dove
61
+ shell: pwsh
62
+ run: |
63
+ $id = (Get-Content analytics.json -Raw | ConvertFrom-Json).progetto
64
+ $lib = (npx analytics percorso) + "\lib"
65
+ $versione = "${{ inputs.versione }}"
66
+ if (-not $versione) { $d = Get-Date; $versione = "$($d.Year).$($d.Month).$($d.Day)" }
67
+ "id=$id" >> $env:GITHUB_OUTPUT
68
+ "lib=$lib" >> $env:GITHUB_OUTPUT
69
+ "versione=$versione" >> $env:GITHUB_OUTPUT
70
+ "progetto $id, versione $versione, ricette da $lib (@noeva/analytics-core $(npx analytics versione))"
71
+
72
+ - name: Il driver ODBC 18, come sui PC e sul server del cliente
73
+ shell: pwsh
74
+ run: |
75
+ choco install sqlserver-odbcdriver -y --no-progress | Out-Null
76
+ Get-OdbcDriver -Platform 64-bit | Select-Object Name
77
+
78
+ # --- mcp ------------------------------------------------------------------
79
+ - name: La cartella e l'eseguibile del servizio MCP
80
+ if: ${{ inputs.cosa == 'mcp' }}
81
+ shell: pwsh
82
+ run: uv run --with pyyaml python "${{ steps.dove.outputs.lib }}\mcp-locale\costruisci.py" . --versione "${{ steps.dove.outputs.versione }}"
83
+
84
+ # Il ramo vivo con il driver 18 e una password finta: deve dire "ok driver" e
85
+ # poi fallire sulla rete. Un errore di import o di driver vuol dire una ricetta
86
+ # PyInstaller da correggere.
87
+ - name: Il ramo vivo dell'eseguibile (pyodbc e driver 18, senza rete)
88
+ if: ${{ inputs.cosa == 'mcp' }}
89
+ shell: pwsh
90
+ run: |
91
+ $id = "${{ steps.dove.outputs.id }}"
92
+ $exe = "dist\mcp-locale\noeva-$id\noeva\noeva-$id.exe"
93
+ $conf = Get-Content "dist\mcp-locale\noeva-$id\noeva\noeva.json" -Raw | ConvertFrom-Json
94
+ $vive = $conf.sorgenti.PSObject.Properties | Where-Object { $_.Value.tipo -eq 'mssql' }
95
+ if (-not $vive) { "nessuna sorgente viva: niente da provare"; exit 0 }
96
+ foreach ($v in $vive) {
97
+ if ($v.Value.password_env) { [Environment]::SetEnvironmentVariable($v.Value.password_env, 'non-e-la-password', 'Process') }
98
+ $out = "$env:RUNNER_TEMP\verifica-$($v.Name).txt"
99
+ $p = Start-Process $exe -ArgumentList '--verifica', '--sorgente', $v.Name -NoNewWindow -PassThru -RedirectStandardOutput $out
100
+ $tempo = [Diagnostics.Stopwatch]::StartNew()
101
+ if (-not $p.WaitForExit(180000)) { $p.Kill(); " (fermato dopo 180 s)" }
102
+ $testo = Get-Content $out -Raw
103
+ $testo
104
+ " durata: $([int]$tempo.Elapsed.TotalSeconds) s"
105
+ if ($testo -match 'pyodbc non utilizzabile|non e'' installato') { throw "il ramo vivo non arriva al login: ricetta da correggere" }
106
+ if ($testo -notmatch 'ok driver') { throw "nessun 'ok driver' per $($v.Name)" }
107
+ }
108
+ exit 0
109
+
110
+ - name: installa.ps1 parte e chiude con il riepilogo
111
+ if: ${{ inputs.cosa == 'mcp' }}
112
+ shell: pwsh
113
+ run: |
114
+ $dir = "dist\mcp-locale\noeva-${{ steps.dove.outputs.id }}"
115
+ $out = (powershell -NoProfile -ExecutionPolicy Bypass -File "$dir\installa.ps1" -SoloControllo 2>&1 | Out-String)
116
+ $out
117
+ if ($out -notmatch 'RIEPILOGO' -or $out -notmatch 'NON COMPLETATA') { throw "installa.ps1 non arriva al riepilogo" }
118
+ $esito = Get-ChildItem $dir -Filter 'esito-installa-*.txt'
119
+ if (-not $esito) { throw "installa.ps1 non scrive il file dell'esito" }
120
+ Remove-Item $esito.FullName
121
+ "ok: installa.ps1 gira con PowerShell 5.1 e chiude con l'esito"
122
+ exit 0
123
+
124
+ # --- snapshot -------------------------------------------------------------
125
+ - name: L'eseguibile degli snapshot e il pacchetto del progetto
126
+ if: ${{ inputs.cosa == 'snapshot' }}
127
+ shell: pwsh
128
+ run: |
129
+ uv run --with pyyaml python "${{ steps.dove.outputs.lib }}\snapshot\impacchetta.py" eseguibile .
130
+ uv run --with pyyaml python "${{ steps.dove.outputs.lib }}\snapshot\impacchetta.py" pacchetto .
131
+
132
+ - name: Prova dell'eseguibile fino al login
133
+ if: ${{ inputs.cosa == 'snapshot' }}
134
+ shell: pwsh
135
+ run: |
136
+ $id = "${{ steps.dove.outputs.id }}"
137
+ $exe = "dist\snapshot\noeva-snapshot\noeva-snapshot.exe"
138
+ $pac = "dist\snapshot\pacchetti\$id"
139
+ & $exe versione
140
+ if ($LASTEXITCODE -ne 0) { throw "versione: uscita $LASTEXITCODE" }
141
+ $conf = Get-Content "$pac\connessione.json" -Raw | ConvertFrom-Json
142
+ if ($conf.password_env) { [Environment]::SetEnvironmentVariable($conf.password_env, 'non-e-la-password', 'Process') }
143
+ $out = "$env:RUNNER_TEMP\verifica.txt"
144
+ $p = Start-Process $exe -ArgumentList 'verifica', '--pacchetto', $pac, '--connessione', "$pac\connessione.json" -NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError "$out.err"
145
+ if (-not $p.WaitForExit(240000)) { $p.Kill(); " (fermato dopo 240 s)" }
146
+ $testo = (Get-Content $out -Raw -Encoding utf8) + (Get-Content "$out.err" -Raw -Encoding utf8)
147
+ $testo
148
+ if ($testo -match 'ModuleNotFoundError|ImportError|No module named|non e'' installato|Data source name not found|manca la password') { throw "l'eseguibile non arriva al login: ricetta da correggere" }
149
+ if ($testo -notmatch 'TCP Provider|Login timeout|timeout|08001|HYT00|network') { throw "atteso un errore di rete al login, non trovato" }
150
+ "ok: l'eseguibile arriva al login, si ferma sulla rete come deve"
151
+ # ascolta (ADR 0004): con un'API che non risponde deve dirlo e uscire con 1.
152
+ $w = Get-Content dist\snapshot\worker.json -Raw | ConvertFrom-Json
153
+ $w.api = 'http://127.0.0.1:9'
154
+ $w | ConvertTo-Json -Depth 5 | Set-Content "$env:RUNNER_TEMP\worker.json" -Encoding utf8
155
+ Copy-Item dist\snapshot\pacchetti "$env:RUNNER_TEMP\pacchetti" -Recurse -Force
156
+ $env:NOEVA_API_KEY = 'noeva_sk_finta'
157
+ $testo = (& $exe ascolta --config "$env:RUNNER_TEMP\worker.json" --una-volta 2>&1 | Out-String)
158
+ $testo
159
+ if ($testo -notmatch "in ascolto per analytics-snapshot-$id" -or $testo -notmatch 'non riesco a chiedere lavoro') { throw "ascolta non parte come deve" }
160
+ $out = (powershell -NoProfile -ExecutionPolicy Bypass -File dist\snapshot\installa.ps1 -SoloControllo 2>&1 | Out-String)
161
+ $out
162
+ if ($out -notmatch 'RIEPILOGO' -or $out -notmatch 'NON COMPLETATA') { throw "installa.ps1 non arriva al riepilogo" }
163
+ Get-ChildItem dist\snapshot -Filter 'esito-installa-*.txt' | Remove-Item
164
+ exit 0
165
+
166
+ - name: Lo zip degli snapshot
167
+ if: ${{ inputs.cosa == 'snapshot' }}
168
+ shell: pwsh
169
+ run: Compress-Archive -Path dist\snapshot\noeva-snapshot, dist\snapshot\pacchetti, dist\snapshot\worker.json, dist\snapshot\*.ps1 -DestinationPath dist\snapshot\noeva-snapshot-windows.zip
170
+
171
+ # --- artefatto e versione permanente --------------------------------------
172
+ - uses: actions/upload-artifact@v4
173
+ with:
174
+ name: ${{ inputs.cosa }}-${{ steps.dove.outputs.versione }}
175
+ path: |
176
+ dist/mcp-locale/*.zip
177
+ dist/snapshot/noeva-snapshot-windows.zip
178
+ retention-days: 7
179
+
180
+ - name: La versione permanente (release)
181
+ if: ${{ inputs.rilascia }}
182
+ shell: pwsh
183
+ env:
184
+ GH_TOKEN: ${{ github.token }}
185
+ run: |
186
+ $id = "${{ steps.dove.outputs.id }}"
187
+ $versione = "${{ steps.dove.outputs.versione }}"
188
+ if ("${{ inputs.cosa }}" -eq "mcp") {
189
+ $tag = "noeva-$id-v$versione"; $titolo = "noeva-$id $versione"
190
+ $zip = "dist\mcp-locale\noeva-$id-$versione.zip"; Copy-Item "dist\mcp-locale\noeva-$id.zip" $zip
191
+ $guida = "docs\installazione-$id-windows.md"
192
+ $note = "Cartella per Claude Code su Windows: noeva-$id.exe, dal commit $env:GITHUB_SHA. Il modello arriva da solo dall'ultima versione pubblicata. Installazione: $guida (allegata); poi ``noeva\noeva-$id.exe --verifica``."
193
+ $latest = "--latest"
194
+ } else {
195
+ $tag = "noeva-snapshot-$id-v$versione"; $titolo = "noeva-snapshot $id $versione"
196
+ $zip = "dist\snapshot\noeva-snapshot-$id-windows-$versione.zip"; Copy-Item dist\snapshot\noeva-snapshot-windows.zip $zip
197
+ $guida = "docs\installazione-snapshot-$id-windows.md"
198
+ $note = "noeva-snapshot.exe per Windows e il pacchetto di $id (snapshot.yml, mdl.json, connessione.json senza password), dal commit $env:GITHUB_SHA. ADR 0004. Installazione: $guida (allegata)."
199
+ # la release in evidenza resta quella della cartella MCP che il cliente usa
200
+ $latest = "--latest=false"
201
+ }
202
+ gh release view $tag 2>$null
203
+ if ($LASTEXITCODE -eq 0) { throw "la versione $tag esiste gia': una versione non si riscrive, scegline un'altra" }
204
+ $file = @($zip)
205
+ if (Test-Path $guida) { $file += $guida }
206
+ gh release create $tag @file --target $env:GITHUB_SHA --title $titolo --notes $note $latest
207
+ "pubblicata $tag"