@noeva/analytics-core 0.1.0 → 0.3.2

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
@@ -12,8 +12,10 @@ import {
12
12
  cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
13
13
  } from 'node:fs';
14
14
  import { dirname, join, relative, sep } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
15
16
 
16
- export const PACCHETTO = new URL('.', import.meta.url).pathname;
17
+ // fileURLToPath e non .pathname: su Windows .pathname e' /D:/... e il percorso diventa D:\D:\...
18
+ export const PACCHETTO = fileURLToPath(new URL('.', import.meta.url));
17
19
  export const VERSIONE = JSON.parse(readFileSync(join(PACCHETTO, 'package.json'), 'utf8')).version;
18
20
  const MODELLO = join(PACCHETTO, 'modello', 'repository');
19
21
  const KIT = join(PACCHETTO, 'kit');
@@ -50,7 +52,12 @@ export function leggiConfig(radice) {
50
52
  if (!Array.isArray(nonGestiti) || nonGestiti.some((p) => typeof p !== 'string')) {
51
53
  throw new ErroreConfig('analytics.json: `non_gestiti` deve essere un elenco di percorsi');
52
54
  }
53
- return { progetto: c.progetto, nonGestiti };
55
+ // Il workspace NOEVA che possiede il progetto: lo nomina il biglietto con cui
56
+ // `analytics pubblica` scrive le pagine. Non e' un segreto, e' un id.
57
+ if (c.workspace !== undefined && typeof c.workspace !== 'string') {
58
+ throw new ErroreConfig('analytics.json: `workspace` deve essere l\'id del workspace NOEVA');
59
+ }
60
+ return { progetto: c.progetto, nonGestiti, ...(c.workspace ? { workspace: c.workspace } : {}) };
54
61
  }
55
62
 
56
63
  // --- file gestiti ---------------------------------------------------------
@@ -78,7 +85,9 @@ export function fileGestiti(config) {
78
85
  for (const f of readdirSync(join(CONTENUTO, 'assets')).sort()) {
79
86
  voci.push({ percorso: `apps/assets/${f}`, sorgente: join(CONTENUTO, 'assets', f) });
80
87
  }
81
- voci.push({ percorso: '.github/workflows/ci.yml', sorgente: join(MODELLO, 'github', 'workflows', 'ci.yml') });
88
+ for (const w of ['ci.yml', 'rilascio-windows.yml']) {
89
+ voci.push({ percorso: `.github/workflows/${w}`, sorgente: join(MODELLO, 'github', 'workflows', w) });
90
+ }
82
91
  for (const nome of readdirSync(join(KIT, 'skills')).sort()) {
83
92
  const base = join(KIT, 'skills', nome);
84
93
  for (const f of fileSotto(base).sort()) {
@@ -269,6 +278,15 @@ export function verifica(radice, config) {
269
278
 
270
279
  // L'ultima versione pubblicata, o null se il registry non risponde o il pacchetto
271
280
  // non c'e': il controllo resta in sola lettura e non fallisce per la rete.
281
+ /** a e' una versione piu' nuova di b? (x.y.z; un prerilascio o un formato strano non lo e') */
282
+ export function piuNuova(a, b) {
283
+ const n = (v) => (/^\d+\.\d+\.\d+$/.test(v || '') ? v.split('.').map(Number) : null);
284
+ const [x, y] = [n(a), n(b)];
285
+ if (!x || !y) return false;
286
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
287
+ return false;
288
+ }
289
+
272
290
  export async function ultimaVersione(fetchImpl = fetch) {
273
291
  try {
274
292
  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"