@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.
- package/CHANGELOG.md +48 -0
- package/README.md +4 -0
- package/cli.js +34 -4
- package/contenuto/lib/mcp-locale/ambiente/.python-version +1 -0
- package/contenuto/lib/mcp-locale/ambiente/pyproject.toml +23 -0
- package/contenuto/lib/mcp-locale/ambiente/uv.lock +1103 -0
- package/contenuto/lib/mcp-locale/cartella/CLAUDE.md +52 -0
- package/contenuto/lib/mcp-locale/cartella/LEGGIMI.md +163 -0
- package/contenuto/lib/mcp-locale/cartella/imposta-password.ps1 +40 -0
- package/contenuto/lib/mcp-locale/cartella/installa.ps1 +286 -0
- package/contenuto/lib/mcp-locale/costruisci.py +495 -0
- package/contenuto/lib/mcp-locale/prova.py +93 -0
- package/contenuto/lib/mcp-locale/server/modello_locale.py +167 -0
- package/contenuto/lib/mcp-locale/server/noeva_mcp.py +203 -0
- package/contenuto/lib/modello/consegna.py +134 -0
- package/contenuto/lib/modello/pubblica.py +131 -0
- package/contenuto/lib/snapshot/ascolta.py +213 -0
- package/contenuto/lib/snapshot/impacchetta.py +184 -0
- package/contenuto/lib/snapshot/noeva_snapshot.py +107 -0
- package/contenuto/lib/snapshot/pubblica.py +35 -15
- package/contenuto/lib/snapshot/worker/installa.ps1 +340 -0
- package/core.js +21 -3
- package/modello/repository/github/workflows/ci.yml +53 -3
- package/modello/repository/github/workflows/rilascio-windows.yml +207 -0
- package/modello/repository/gitignore +2 -1
- package/package.json +2 -1
- package/pubblica.js +211 -0
|
@@ -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"
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
.env.local
|
|
4
4
|
.dev.vars
|
|
5
5
|
|
|
6
|
-
# Il pacchetto
|
|
6
|
+
# Il pacchetto, il catalogo compilato e le cartelle Windows (rilascio-windows.yml) si rigenerano.
|
|
7
7
|
node_modules/
|
|
8
8
|
target/
|
|
9
|
+
dist/
|
|
9
10
|
|
|
10
11
|
# Il motore wren-core-wasm (68 MB): in produzione arriva dal contenitore sotto /vendor/.
|
|
11
12
|
apps/vendor/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noeva/analytics-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Gli strumenti di un progetto NOEVA Analytics nel suo repository: moduli di pagina, snapshot, verifica dello standard e kit dell'agente (ADR 0005).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.js",
|
|
11
11
|
"core.js",
|
|
12
|
+
"pubblica.js",
|
|
12
13
|
"kit/",
|
|
13
14
|
"modello/",
|
|
14
15
|
"contenuto/",
|
package/pubblica.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// `analytics pubblica`: la apps/ del progetto sul contenitore, in R2 (ADR 0005, punto 3).
|
|
2
|
+
//
|
|
3
|
+
// Si pubblica il commit, non il disco: i file di apps/ tracciati in HEAD, meno
|
|
4
|
+
// quelli che apps/.assetsignore esclude (lo snapshot, che ha la sua strada, il
|
|
5
|
+
// motore, i file di lavoro). Cosi' quello che va online e' quello che `analytics
|
|
6
|
+
// ci` ha verificato, in CI come sul Mac, e un file lasciato fuori da git non
|
|
7
|
+
// parte per sbaglio.
|
|
8
|
+
//
|
|
9
|
+
// Il contenitore accetta una versione solo con un biglietto di tipo `pagine` per
|
|
10
|
+
// quel progetto e quella versione, con il workspace che possiede il progetto:
|
|
11
|
+
// firmato da chi ha il segreto del contenitore, o rilasciato dall'API NOEVA alla
|
|
12
|
+
// chiave di un membro del workspace (chiediBiglietto, sotto).
|
|
13
|
+
//
|
|
14
|
+
// Il controllo dei percorsi e' lo stesso di lib/analytics/pagine-r2.js, che
|
|
15
|
+
// rifiuterebbe comunque: qui si ferma prima di caricare mezza versione. Una
|
|
16
|
+
// prova del monorepo li confronta.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { createHmac } from 'node:crypto';
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { ErroreConfig } from './core.js';
|
|
24
|
+
|
|
25
|
+
const SEGMENTO = /^[A-Za-z0-9_@][A-Za-z0-9_.@-]{0,99}$/;
|
|
26
|
+
const PROFONDITA = 8;
|
|
27
|
+
const ESTENSIONI = new Set([
|
|
28
|
+
'html', 'js', 'mjs', 'css', 'json', 'md', 'txt', 'csv', 'svg', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'ico',
|
|
29
|
+
'pdf', 'woff', 'woff2', 'parquet', 'wasm',
|
|
30
|
+
]);
|
|
31
|
+
const IN_PARALLELO = 6;
|
|
32
|
+
const DURATA_BIGLIETTO_MS = 15 * 60 * 1000;
|
|
33
|
+
const API_NOEVA = 'https://api.noeva.ai';
|
|
34
|
+
|
|
35
|
+
/** Un percorso di file della apps/ che il contenitore accetta (pagine-r2.js). */
|
|
36
|
+
export function percorsoAmmesso(file) {
|
|
37
|
+
if (typeof file !== 'string' || !file) return false;
|
|
38
|
+
const segmenti = file.split('/');
|
|
39
|
+
if (segmenti.length > PROFONDITA || !segmenti.every((s) => SEGMENTO.test(s))) return false;
|
|
40
|
+
const nome = segmenti.at(-1);
|
|
41
|
+
return ESTENSIONI.has(nome.slice(nome.lastIndexOf('.') + 1).toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Le regole di un .gitignore semplice, come le legge assembla.py: `vendor/**`,
|
|
45
|
+
// `dati/*.parquet`, `dati/chat/**`. Un `*` non attraversa le cartelle.
|
|
46
|
+
export function regoleIgnorate(testo) {
|
|
47
|
+
return testo.split('\n').map((r) => r.trim()).filter((r) => r && !r.startsWith('#'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const comeRegex = (glob) => new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\0/g, '.*')}$`);
|
|
51
|
+
|
|
52
|
+
export function ignorato(rel, regole) {
|
|
53
|
+
return regole.some((r) => (r.endsWith('/**') ? rel.startsWith(r.slice(0, -2)) : comeRegex(r).test(rel)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const git = (radice, args, opzioni = {}) => execFileSync('git', args, { cwd: radice, maxBuffer: 256 * 1024 * 1024, ...opzioni });
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* I file da pubblicare: {file, byte, oggetto} per ogni file di apps/ in HEAD che
|
|
60
|
+
* .assetsignore (di HEAD) non esclude. `scartati` sono quelli con un percorso o
|
|
61
|
+
* un tipo che il contenitore rifiuterebbe.
|
|
62
|
+
*/
|
|
63
|
+
export function filePubblicabili(radice) {
|
|
64
|
+
let righe;
|
|
65
|
+
try {
|
|
66
|
+
righe = git(radice, ['ls-tree', '-r', '-l', '-z', 'HEAD', '--', 'apps/'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new ErroreConfig('git ls-tree HEAD non risponde: serve un repository git con almeno un commit');
|
|
69
|
+
}
|
|
70
|
+
let regole = [];
|
|
71
|
+
try {
|
|
72
|
+
regole = regoleIgnorate(git(radice, ['show', 'HEAD:apps/.assetsignore'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
73
|
+
} catch { /* nessun .assetsignore: si pubblica tutto, e analytics ci lo segnala */ }
|
|
74
|
+
|
|
75
|
+
const file = [];
|
|
76
|
+
const scartati = [];
|
|
77
|
+
for (const riga of righe) {
|
|
78
|
+
// <modo> <tipo> <oggetto> <byte>\t<percorso>
|
|
79
|
+
const [testa, percorso] = riga.split('\t');
|
|
80
|
+
const [, tipo, oggetto, byte] = testa.trim().split(/\s+/);
|
|
81
|
+
if (tipo !== 'blob') continue;
|
|
82
|
+
const rel = percorso.slice('apps/'.length);
|
|
83
|
+
const nome = rel.split('/').at(-1);
|
|
84
|
+
if (nome === '.assetsignore' || nome === '.DS_Store' || nome === '.gitkeep' || ignorato(rel, regole)) continue;
|
|
85
|
+
if (!percorsoAmmesso(rel)) { scartati.push(rel); continue; }
|
|
86
|
+
file.push({ file: rel, byte: Number(byte), oggetto });
|
|
87
|
+
}
|
|
88
|
+
return { file, scartati };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** La versione: data e ora UTC del commit e il suo sha corto, unica e leggibile. */
|
|
92
|
+
export function versioneDi(radice) {
|
|
93
|
+
const [sha, quando] = git(radice, ['log', '-1', '--format=%h%x09%cI'], { encoding: 'utf8' }).trim().split('\t');
|
|
94
|
+
const t = new Date(quando).toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
95
|
+
return `${t}-${sha}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lo stesso formato di lib/cancello/sessione.js: base64url(JSON).base64url(HMAC-SHA256). */
|
|
99
|
+
export function firmaBiglietto(payload, segreto) {
|
|
100
|
+
const corpo = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
101
|
+
const mac = createHmac('sha256', segreto).update(corpo).digest('base64url');
|
|
102
|
+
return `${corpo}.${mac}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Il valore della variabile o, se manca, quello del file nella home (permessi
|
|
106
|
+
// 600, mai nel repository): come dal_file di lib/snapshot/pubblica.py.
|
|
107
|
+
function dalFile(env, variabile, nome, casa) {
|
|
108
|
+
if (env[variabile]) return env[variabile].trim();
|
|
109
|
+
const f = join(casa, nome);
|
|
110
|
+
return existsSync(f) ? readFileSync(f, 'utf8').trim() || null : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Il biglietto per scrivere `tipo` (pagine) di questo progetto e versione. Due
|
|
115
|
+
* strade, come per i dati (lib/snapshot/pubblica.py): firmato qui col segreto del
|
|
116
|
+
* contenitore, sul Mac di chi lo amministra; o rilasciato dall'API NOEVA a chi ha
|
|
117
|
+
* la chiave di un membro del workspace, ed e' la strada della CI del repository.
|
|
118
|
+
*/
|
|
119
|
+
export async function chiediBiglietto({ progetto, versione, workspace, tipo, env, casa, fetchImpl }) {
|
|
120
|
+
const da = env.GITHUB_ACTIONS ? `ci ${env.GITHUB_REPOSITORY ?? ''}`.trim() : `analytics ${tipo}`;
|
|
121
|
+
const segreto = dalFile(env, 'SEGRETO_DATI', '.segreto-dati', casa);
|
|
122
|
+
if (segreto) {
|
|
123
|
+
return firmaBiglietto({ tipo, prog: progetto, ver: versione, ws: workspace, da, exp: Date.now() + DURATA_BIGLIETTO_MS }, segreto);
|
|
124
|
+
}
|
|
125
|
+
const chiave = dalFile(env, 'NOEVA_API_KEY', '.noeva-api-key', casa);
|
|
126
|
+
if (!chiave) {
|
|
127
|
+
throw new ErroreConfig('nessun biglietto: serve la chiave API NOEVA di un membro del workspace (NOEVA_API_KEY o ' +
|
|
128
|
+
'~/.noeva-api-key; in CI il secret del repository), o il segreto del contenitore (SEGRETO_DATI o ~/.segreto-dati)');
|
|
129
|
+
}
|
|
130
|
+
const api = (env.NOEVA_API || API_NOEVA).replace(/\/+$/, '');
|
|
131
|
+
const r = await fetchImpl(`${api}/api/analytics/biglietto-dati`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${chiave}`, 'x-noeva-workspace': workspace,
|
|
135
|
+
'Content-Type': 'application/json', 'User-Agent': 'noeva-analytics-core',
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({ progetto, versione, da, tipo }),
|
|
138
|
+
});
|
|
139
|
+
let corpo = {};
|
|
140
|
+
try { corpo = await r.json(); } catch { /* il messaggio d'errore sotto basta */ }
|
|
141
|
+
if (!r.ok || !corpo?.payload?.biglietto) {
|
|
142
|
+
throw new ErroreConfig(`l'API NOEVA non rilascia il biglietto (${r.status}): ${corpo?.message ?? corpo?.error ?? 'risposta inattesa'}`);
|
|
143
|
+
}
|
|
144
|
+
return corpo.payload.biglietto;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function chiama(fetchImpl, metodo, url, biglietto, corpo, tipo) {
|
|
148
|
+
// Niente redirect: un contenitore senza la rotta manda al login con un 302, e
|
|
149
|
+
// seguirlo darebbe un 200 (la pagina di login) scambiato per una pubblicazione.
|
|
150
|
+
const r = await fetchImpl(url, {
|
|
151
|
+
method: metodo,
|
|
152
|
+
body: corpo,
|
|
153
|
+
redirect: 'manual',
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${biglietto}`,
|
|
156
|
+
'User-Agent': 'noeva-analytics-core',
|
|
157
|
+
...(corpo !== undefined ? { 'Content-Length': String(corpo.length), 'Content-Type': tipo } : {}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const testo = await r.text();
|
|
161
|
+
let risposta;
|
|
162
|
+
try { risposta = JSON.parse(testo); } catch { risposta = { errore: testo.slice(0, 200) }; }
|
|
163
|
+
if (!r.ok || risposta.ok !== true) {
|
|
164
|
+
const dettaglio = risposta.mancanti ? ` (${risposta.mancanti.join(', ')})` : '';
|
|
165
|
+
const perche = r.status >= 300 && r.status < 400
|
|
166
|
+
? `rimanda a ${r.headers.get('Location') ?? '?'}: il contenitore non ha questa rotta (va deployato?)`
|
|
167
|
+
: (risposta.errore ?? '');
|
|
168
|
+
throw new ErroreConfig(`${metodo} ${new URL(url).pathname}: ${r.status} ${perche}${dettaglio}`);
|
|
169
|
+
}
|
|
170
|
+
return risposta;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pubblica la apps/ di HEAD: carica ogni file e sposta il puntatore. `prova`
|
|
175
|
+
* elenca e basta. Restituisce {versione, file, byte, scartati}.
|
|
176
|
+
*/
|
|
177
|
+
export async function pubblica(radice, config, {
|
|
178
|
+
url, workspace = config.workspace, env = process.env, fetchImpl = fetch, prova = false, stampa = () => {}, casa = homedir(),
|
|
179
|
+
} = {}) {
|
|
180
|
+
const { file, scartati } = filePubblicabili(radice);
|
|
181
|
+
if (scartati.length) {
|
|
182
|
+
throw new ErroreConfig(`file che il contenitore non accetta (percorso o tipo): ${scartati.join(', ')}. ` +
|
|
183
|
+
'Si escludono in apps/.assetsignore o si rinominano.');
|
|
184
|
+
}
|
|
185
|
+
if (!file.some((f) => f.file === 'index.html')) throw new ErroreConfig('apps/index.html manca in HEAD');
|
|
186
|
+
const versione = versioneDi(radice);
|
|
187
|
+
const byte = file.reduce((s, f) => s + f.byte, 0);
|
|
188
|
+
const esito = { versione, file: file.length, byte, scartati };
|
|
189
|
+
if (prova) return esito;
|
|
190
|
+
|
|
191
|
+
if (!url) throw new ErroreConfig('serve --url: l\'indirizzo del contenitore (in prova http://localhost:8789)');
|
|
192
|
+
if (!workspace) throw new ErroreConfig('serve il workspace NOEVA che possiede il progetto: `workspace` in analytics.json, o --workspace');
|
|
193
|
+
const biglietto = await chiediBiglietto({
|
|
194
|
+
progetto: config.progetto, versione, workspace, tipo: 'pagine', env, casa, fetchImpl,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const base = `${url.replace(/\/+$/, '')}/_pagine/${config.progetto}/${versione}`;
|
|
198
|
+
stampa(`▸ ${config.progetto}: versione ${versione}, ${file.length} file, ${Math.round(byte / 1024)} kB -> ${url}`);
|
|
199
|
+
const coda = [...file];
|
|
200
|
+
const lavora = async () => {
|
|
201
|
+
for (let f = coda.shift(); f; f = coda.shift()) {
|
|
202
|
+
const corpo = git(radice, ['cat-file', 'blob', f.oggetto]);
|
|
203
|
+
const percorso = f.file.split('/').map(encodeURIComponent).join('/');
|
|
204
|
+
await chiama(fetchImpl, 'PUT', `${base}/${percorso}`, biglietto, corpo, 'application/octet-stream');
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
await Promise.all(Array.from({ length: IN_PARALLELO }, lavora));
|
|
208
|
+
const manifesto = Buffer.from(JSON.stringify({ file: file.map(({ file: f, byte: b }) => ({ file: f, byte: b })) }));
|
|
209
|
+
await chiama(fetchImpl, 'POST', `${base}/pubblica`, biglietto, manifesto, 'application/json');
|
|
210
|
+
return esito;
|
|
211
|
+
}
|