@noeva/analytics-core 0.1.0 → 0.3.1
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 +42 -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 +18 -2
- 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
|
@@ -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.1
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
+
}
|