@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,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 e il catalogo compilato si rigenerano.
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.1.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
+ }