@elia97/officina 0.8.0 → 0.9.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/README.md +23 -6
- package/dist/checks/placeholders.js +30 -13
- package/dist/checks/smoke.js +4 -2
- package/dist/lib/check-language.js +3 -2
- package/dist/lib/check-placeholders.d.ts +2 -1
- package/dist/lib/check-placeholders.js +30 -6
- package/dist/lib/config.d.ts +2 -1
- package/dist/lib/config.js +2 -2
- package/dist/lib/smoke-production.d.ts +2 -1
- package/dist/lib/smoke-production.js +32 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ pnpm add -D @elia97/officina
|
|
|
36
36
|
|
|
37
37
|
I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni dei gate sui sorgenti: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
|
|
38
38
|
|
|
39
|
+
`check language` vuole in italiano i commenti del codice e i Markdown di documentazione. Lascia fuori per intero ciò che non è documentazione del progetto: i dizionari di `src/i18n/strings/` e i Markdown sotto `src/content/`, che sono copy nelle lingue del sito; le fonti del cliente in `docs/sources/`; i `CHANGELOG.md`, generati in inglese. Un file YAML di `src/content/` invece è codice, e i suoi commenti si controllano come altrove.
|
|
40
|
+
|
|
39
41
|
## Verifiche di build e di produzione
|
|
40
42
|
|
|
41
43
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
@@ -50,6 +52,8 @@ Una rotta renderizzata a richiesta non emette HTML. Con l'adapter Vercel, `check
|
|
|
50
52
|
|
|
51
53
|
Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
|
|
52
54
|
|
|
55
|
+
Lo smoke legge gli header di sicurezza sulla pagina a cui arriva la radice. Se `/` è un redirect sulla stessa origine — il 302 di lingua che Astro emette con `i18n.routing.redirectToDefaultLocale`, per esempio — li cerca sulla destinazione, perché il redirect non porta la CSP delle pagine renderizzate, e il nome del controllo dice dove li ha letti: `header content-security-policy su /it`. Un redirect verso un'altra origine, o senza `location`, è un fallimento. Anche l'attesa che l'alias di produzione punti al deployment nuovo conta un 3xx come una risposta.
|
|
56
|
+
|
|
53
57
|
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
54
58
|
|
|
55
59
|
```ts
|
|
@@ -66,7 +70,7 @@ export default defineConfig({
|
|
|
66
70
|
icons: { background: SITE.themeColor.light },
|
|
67
71
|
bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
|
|
68
72
|
smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
|
|
69
|
-
features: { analytics: 'required', roadmap: false },
|
|
73
|
+
features: { analytics: 'required', roadmap: false, botId: 'required' },
|
|
70
74
|
routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
|
|
71
75
|
})
|
|
72
76
|
```
|
|
@@ -79,14 +83,16 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
79
83
|
| `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
|
|
80
84
|
| `@elia97/officina/icons` | `ICON_SPECS` |
|
|
81
85
|
|
|
82
|
-
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi
|
|
86
|
+
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
|
|
83
87
|
|
|
84
|
-
`features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
|
|
88
|
+
`features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Per BotID è `features.botId`: con `false` lo smoke salta la challenge e lo scrive nell'uscita, anche dentro una lista di `smoke.checks` che la contiene. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
|
|
85
89
|
|
|
86
|
-
`features.generators` ha un valore in più delle altre
|
|
90
|
+
`features.generators` ha un valore in più delle altre, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
|
|
87
91
|
|
|
88
92
|
`placeholders` dice a `check placeholders` cosa guardare: `sources` sono i file da scandire, `contactEnvKeys` le variabili che `--env` pretende. Una lista dichiarata **sostituisce** quella del pacchetto, dizionari compresi: senza dichiarazione i sorgenti sono `src/lib/site.ts`, `src/lib/company.ts` e i dizionari di `src/i18n/strings`, quando la cartella esiste. Serve ai progetti che non nascono dallo scaffold: se partita IVA, recapiti e destinatario dei form stanno nel contenuto invece che in un modulo, i sorgenti del template non sono i suoi, e crearli per far passare il gate terrebbe gli stessi dati in due sedi. Un sorgente elencato che non esiste è un ritrovamento con il suo percorso, non un errore che ferma il comando.
|
|
89
93
|
|
|
94
|
+
Nel deploy `check placeholders` gira due volte sui file di Vercel. Con `--env .vercel/.env.production.local`, dopo `vercel pull`, distingue una chiave che su Vercel non esiste, una vuota e una che porta un segnaposto del template, tutte e tre errori, da una variabile **Sensitive**: `vercel pull` non ne scarica il valore e scrive al suo posto `[SENSITIVE]`, quindi il check lo dice con un avviso, che non ferma il deploy, invece di darla per verificata. Il segnaposto però diventa un problema se la build legge la variabile, per esempio una variabile di `astro:env` con `access: 'public'`, che Astro incorpora nel codice: con `--output .vercel/output`, dopo `vercel build`, il check cerca `[SENSITIVE]` nei file della build e ferma il deploy nominandoli. Una variabile che la build legge va tenuta Encrypted su Vercel, o letta a runtime.
|
|
95
|
+
|
|
90
96
|
Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`. Due file `officina.config.*` insieme sono un errore, come un file senza default export; il resto lo valida `loadConfig` a runtime, nominando il percorso della voce sbagliata — `bundle.cssMaxGzip: atteso un numero, ricevuto una stringa` — e una voce sconosciuta, perché `sitUrl` al posto di `siteUrl` non deve passare in silenzio.
|
|
91
97
|
|
|
92
98
|
`sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. Un ingresso che manca — il favicon, `.lighthouserc.json`, `src/pages`, la build in `dist/client` — è sempre una riga che nomina il file e dice a cosa serve, mai uno stack trace. `@biomejs/biome` invece è una peer dependency con l'intervallo che il preset regge, e `doctor` confronta la versione del progetto con quello.
|
|
@@ -116,7 +122,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
116
122
|
|---|---|---|
|
|
117
123
|
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
118
124
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
119
|
-
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
|
|
125
|
+
| `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
|
|
120
126
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
121
127
|
|
|
122
128
|
```yaml
|
|
@@ -165,6 +171,8 @@ Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
|
|
|
165
171
|
|
|
166
172
|
`package.json`, `.claude/settings.json`, `biome.json` e `.mcp.json` si leggono come JSON, non cercandoci dentro delle stringhe: `"hooks"` in un valore qualunque non è un blocco `hooks`. I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga commentata non soddisfa nessun controllo.
|
|
167
173
|
|
|
174
|
+
**Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi.
|
|
175
|
+
|
|
168
176
|
## Sviluppo
|
|
169
177
|
|
|
170
178
|
```sh
|
|
@@ -181,13 +189,22 @@ I sorgenti sono TypeScript, ma il pacchetto pubblica JavaScript: Node toglie i t
|
|
|
181
189
|
|
|
182
190
|
## Pubblicare
|
|
183
191
|
|
|
192
|
+
**Una release a settimana**, salvo una rottura che ferma un progetto: le correzioni si accumulano su `main` ed escono insieme. Ogni release è un aggiornamento da portare in ogni progetto e nel template, e il gruppo `officina` di Dependabot lo propone una volta a settimana: rilasciare più spesso non lo fa arrivare prima, moltiplica soltanto le PR da seguire.
|
|
193
|
+
|
|
184
194
|
```sh
|
|
185
|
-
|
|
195
|
+
npm pkg set version=X.Y.Z
|
|
196
|
+
git diff # una riga sola: "version" in package.json
|
|
197
|
+
git commit -am "chore(release): X.Y.Z"
|
|
198
|
+
git tag -a vX.Y.Z -m "vX.Y.Z"
|
|
186
199
|
git push --follow-tags
|
|
187
200
|
```
|
|
188
201
|
|
|
202
|
+
Il tag va creato **annotato**: `--follow-tags` spinge soltanto i tag annotati, e un tag leggero resta in locale senza nessun errore, quindi il rilascio non parte. È successo con la 0.6.0.
|
|
203
|
+
|
|
189
204
|
Il resto lo fa `.github/workflows/release.yml`, sul push di un tag `v*`: controlla che il tag corrisponda a `version`, rilancia il gate, la build e il test del tarball, pubblica su npm con trusted publishing (OIDC) e provenance, e crea la release su GitHub con le note generate. Niente `pnpm publish` dal terminale: quello che finisce su npm esce da un tag, e da nient'altro.
|
|
190
205
|
|
|
206
|
+
Il rilascio è fatto quando lo confermano tre cose: `git ls-remote --tags origin` mostra `vX.Y.Z^{}`, cioè il tag annotato arrivato sul remoto; il workflow «Rilascio» sul tag è verde; `curl -fsS https://registry.npmjs.org/@elia97%2Fofficina/X.Y.Z` risponde con il manifesto della versione. `npm view`, anche con `@X.Y.Z`, passa invece dal documento aggregato del pacchetto, che arriva da una CDN e per qualche minuto dopo la pubblicazione può ancora rispondere 404.
|
|
207
|
+
|
|
191
208
|
Le action si richiamano per tag, quindi **una versione senza tag non è adottabile**: `doctor` chiede ai progetti la stessa versione del pacchetto installato, e un tag che non esiste non si può scrivere in un workflow.
|
|
192
209
|
|
|
193
210
|
Una volta sola, a mano: su npmjs.com il trusted publisher per `Elia97/officina` e `release.yml`; su GitHub un ruleset sui tag `v*` che ne vieta modifica e cancellazione.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { contactEnvKeys, envFindings, placeholderSources, sourceFindings } from '../lib/check-placeholders.js';
|
|
5
|
+
import { contactEnvKeys, envFindings, outputFindings, placeholderSources, sourceFindings, } from '../lib/check-placeholders.js';
|
|
6
6
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
7
7
|
import { loadConfig } from '../lib/config.js';
|
|
8
8
|
const MISSING_SOURCE = 'sorgente assente: `placeholders.sources` in officina.config.ts dice quali file guardare';
|
|
@@ -16,30 +16,47 @@ function sourceScan(paths) {
|
|
|
16
16
|
function envScan(path, keys) {
|
|
17
17
|
if (!existsSync(path))
|
|
18
18
|
return [{ path, message: "file dell'ambiente assente: lo scrive vercel pull", severity: 'error' }];
|
|
19
|
-
return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit
|
|
19
|
+
return envFindings(readFileSync(path, 'utf8'), keys).map((hit) => ({ path, ...hit }));
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
function outputScan(dir) {
|
|
22
|
+
if (!existsSync(dir))
|
|
23
|
+
return [{ path: dir, message: 'cartella della build assente: la scrive vercel build', severity: 'error' }];
|
|
24
|
+
return outputFindings(dir);
|
|
25
|
+
}
|
|
26
|
+
function scan(env, output, config) {
|
|
27
|
+
if (output !== undefined) {
|
|
28
|
+
return {
|
|
29
|
+
scope: `build in ${output}`,
|
|
30
|
+
findings: outputScan(output),
|
|
31
|
+
clean: 'Nessuna variabile Sensitive entrata nella build.',
|
|
32
|
+
advice: 'Una variabile che la build legge non può essere Sensitive su Vercel: portala a Encrypted, o leggila a runtime',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
22
35
|
if (env !== undefined) {
|
|
23
36
|
return {
|
|
24
37
|
scope: `ambiente in ${env}`,
|
|
25
38
|
findings: envScan(env, contactEnvKeys(config)),
|
|
26
|
-
|
|
39
|
+
clean: 'Nessun segnaposto del template.',
|
|
40
|
+
advice: 'Da sostituire prima del deploy: docs/guides/deploy-ops.md § Checklist per il go-live',
|
|
27
41
|
};
|
|
28
42
|
}
|
|
29
43
|
const paths = placeholderSources(config);
|
|
30
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
scope: `${paths.length} sorgenti`,
|
|
46
|
+
findings: sourceScan(paths),
|
|
47
|
+
clean: 'Nessun segnaposto del template.',
|
|
48
|
+
advice: 'Da sostituire prima del deploy: README.md § Cosa tocca il rebranding',
|
|
49
|
+
};
|
|
31
50
|
}
|
|
32
51
|
export async function main(args = process.argv.slice(2)) {
|
|
33
|
-
const { env } = parseArgs({ args, options: { env: { type: 'string' } } }).values;
|
|
34
|
-
const { scope, findings,
|
|
52
|
+
const { env, output } = parseArgs({ args, options: { env: { type: 'string' }, output: { type: 'string' } } }).values;
|
|
53
|
+
const { scope, findings, clean, advice } = scan(env, output, await loadConfig(process.cwd()));
|
|
35
54
|
console.log(`\ncheck:placeholders — ${scope}\n`);
|
|
36
55
|
printFindings(findings, cliOptions([]).format);
|
|
37
|
-
if (findings.length === 0)
|
|
38
|
-
console.log(
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log(`\n Da sostituire prima del deploy: ${fix}.\n`);
|
|
42
|
-
}
|
|
56
|
+
if (findings.length === 0)
|
|
57
|
+
console.log(` ${clean}\n`);
|
|
58
|
+
else if (findings.some(({ severity }) => severity === 'error'))
|
|
59
|
+
console.log(`\n ${advice}.\n`);
|
|
43
60
|
return exitCode(findings, false);
|
|
44
61
|
}
|
|
45
62
|
if (import.meta.main)
|
package/dist/checks/smoke.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { missingInput } from '../lib/cli.js';
|
|
5
|
-
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { isRequired, loadConfig } from '../lib/config.js';
|
|
6
6
|
import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
|
|
7
7
|
import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
|
|
8
8
|
const PAGES_DIR = 'src/pages';
|
|
@@ -23,7 +23,8 @@ function printFailures(failures) {
|
|
|
23
23
|
console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
|
|
24
24
|
}
|
|
25
25
|
export async function main(args = []) {
|
|
26
|
-
const
|
|
26
|
+
const config = await loadConfig(process.cwd());
|
|
27
|
+
const { siteUrl, smoke = {}, routes = {} } = config;
|
|
27
28
|
const [url] = args;
|
|
28
29
|
if (siteUrl === undefined) {
|
|
29
30
|
console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
|
|
@@ -40,6 +41,7 @@ export async function main(args = []) {
|
|
|
40
41
|
baseUrl,
|
|
41
42
|
siteUrl,
|
|
42
43
|
securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
|
|
44
|
+
botIdRequired: isRequired(config, 'botId'),
|
|
43
45
|
};
|
|
44
46
|
if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
|
|
45
47
|
return 1;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { commentBlocks, styleOf } from './comment-syntax.js';
|
|
2
2
|
import { ENGLISH, ITALIAN } from './language-words.js';
|
|
3
3
|
const CODE = /^(?:(?:src|scripts|test|\.claude\/hooks|\.github)\/.*|[^/]+)\.(?:ts|tsx|astro|mjs|cjs|js|css|ya?ml|jsonc)$/;
|
|
4
|
-
// Fuori le fonti del cliente (materiale altrui) e i changelog, generati in inglese
|
|
5
|
-
|
|
4
|
+
// Fuori le fonti del cliente (materiale altrui) e i changelog, generati in inglese; fuori anche
|
|
5
|
+
// src/content/, della stessa natura dei dizionari in LOCALIZED.
|
|
6
|
+
const DOCS = /^(?!docs\/sources\/|src\/content\/)(?!(?:.*\/)?CHANGELOG\.md$).*\.md$/;
|
|
6
7
|
// I dizionari sono copy per l'utente nelle lingue del sito: en e de leggono inglese e tedesco
|
|
7
8
|
// per definizione, ed è il solo posto sotto CODE dove non deve leggersi italiano.
|
|
8
9
|
const LOCALIZED = /^src\/i18n\/strings\//;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Finding, type Hit } from './cli.ts';
|
|
2
2
|
import type { OfficinaConfig } from './config.ts';
|
|
3
|
-
type EnvHit = Omit<Finding, 'path'
|
|
3
|
+
type EnvHit = Omit<Finding, 'path'>;
|
|
4
4
|
export declare function placeholderSources(config?: OfficinaConfig): string[];
|
|
5
5
|
export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
|
|
6
6
|
export declare function sourceFindings(source: string): Hit[];
|
|
7
7
|
export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
|
|
8
|
+
export declare const outputFindings: (dir: string) => Finding[];
|
|
8
9
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parseEnv } from 'node:util';
|
|
4
4
|
import { readLines } from './cli.js';
|
|
@@ -11,6 +11,8 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
|
|
11
11
|
const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
|
|
12
12
|
const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
|
|
13
13
|
const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
|
|
14
|
+
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@59.22.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
|
|
15
|
+
const SENSITIVE = '[SENSITIVE]';
|
|
14
16
|
const dictionaries = () => existsSync(DICTIONARIES)
|
|
15
17
|
? readdirSync(DICTIONARIES)
|
|
16
18
|
.filter((name) => name.endsWith('.ts'))
|
|
@@ -51,13 +53,35 @@ export function envFindings(content, keys) {
|
|
|
51
53
|
const env = parseEnv(content);
|
|
52
54
|
const lines = readLines(content);
|
|
53
55
|
return keys.flatMap((key) => {
|
|
54
|
-
const value = env[key]?.trim()
|
|
55
|
-
const declaration = lines.find(({ text }) => new RegExp(`^\\s
|
|
56
|
-
|
|
56
|
+
const value = env[key]?.trim();
|
|
57
|
+
const declaration = lines.find(({ text }) => new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`).test(text));
|
|
58
|
+
if (value === undefined || declaration === undefined) {
|
|
59
|
+
const message = `${key} non c'è fra le variabili di produzione su Vercel: vale il default di astro.config.mjs`;
|
|
60
|
+
return [{ severity: 'error', message }];
|
|
61
|
+
}
|
|
62
|
+
const hit = (severity, message) => [
|
|
63
|
+
{ line: declaration.n, severity, message: `${key} ${message}` },
|
|
64
|
+
];
|
|
65
|
+
if (value === SENSITIVE)
|
|
66
|
+
return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
|
|
57
67
|
if (value === '')
|
|
58
|
-
return
|
|
68
|
+
return hit('error', 'è vuota: vale il default di astro.config.mjs');
|
|
59
69
|
if (TOKEN.test(value) || DOMAIN.test(value))
|
|
60
|
-
return
|
|
70
|
+
return hit('error', 'porta un segnaposto del template');
|
|
61
71
|
return [];
|
|
62
72
|
});
|
|
63
73
|
}
|
|
74
|
+
const filesUnder = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
75
|
+
const path = join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory())
|
|
77
|
+
return filesUnder(path);
|
|
78
|
+
return entry.isFile() ? [path] : [];
|
|
79
|
+
});
|
|
80
|
+
export const outputFindings = (dir) => filesUnder(dir)
|
|
81
|
+
.filter((path) => readFileSync(path).includes(SENSITIVE))
|
|
82
|
+
.sort()
|
|
83
|
+
.map((path) => ({
|
|
84
|
+
path,
|
|
85
|
+
severity: 'error',
|
|
86
|
+
message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Sensitive: la build l'ha incorporato al posto del valore`,
|
|
87
|
+
}));
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface OfficinaConfig {
|
|
|
27
27
|
analytics?: FeatureSetting;
|
|
28
28
|
roadmap?: FeatureSetting;
|
|
29
29
|
generators?: GeneratorsSetting;
|
|
30
|
+
botId?: FeatureSetting;
|
|
30
31
|
};
|
|
31
32
|
placeholders?: {
|
|
32
33
|
sources?: readonly string[];
|
|
@@ -37,7 +38,7 @@ export interface OfficinaConfig {
|
|
|
37
38
|
representatives?: Representatives;
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
|
-
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators'];
|
|
41
|
+
export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
|
|
41
42
|
export type FeatureName = (typeof FEATURES)[number];
|
|
42
43
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
43
44
|
export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
|
package/dist/lib/config.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
|
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
|
|
13
|
-
export const FEATURES = ['analytics', 'roadmap', 'generators'];
|
|
13
|
+
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId'];
|
|
14
14
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
15
15
|
export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
|
|
16
16
|
export const generatorsSetting = (config) => config.features?.generators ?? 'required';
|
|
@@ -28,7 +28,7 @@ const SHAPE = aShape({
|
|
|
28
28
|
checks: anArrayOf(aFunction),
|
|
29
29
|
}),
|
|
30
30
|
analytics: aShape({ linkTracking: aString }),
|
|
31
|
-
features: aShape({ analytics: feature, roadmap: feature, generators }),
|
|
31
|
+
features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
|
|
32
32
|
placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
|
|
33
33
|
routes: aShape({ representatives: aRecordOf(aString) }),
|
|
34
34
|
});
|
|
@@ -12,6 +12,7 @@ export interface SmokeContext {
|
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
siteUrl: string;
|
|
14
14
|
securityHeaders: Record<string, string | null>;
|
|
15
|
+
botIdRequired: boolean;
|
|
15
16
|
}
|
|
16
17
|
export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
|
|
17
18
|
export interface CheckResult {
|
|
@@ -26,7 +27,7 @@ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d
|
|
|
26
27
|
export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
|
|
27
28
|
export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
28
29
|
export declare function checkSecurityHeaders({ get, baseUrl, securityHeaders }: SmokeContext): Promise<CheckResult[]>;
|
|
29
|
-
export declare function checkBotIdChallenge({ get, baseUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
30
|
+
export declare function checkBotIdChallenge({ get, baseUrl, botIdRequired }: SmokeContext): Promise<CheckResult[]>;
|
|
30
31
|
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
31
32
|
export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
32
33
|
export declare function checkTrailingSlash({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
@@ -13,11 +13,14 @@ export const SECURITY_HEADERS = {
|
|
|
13
13
|
};
|
|
14
14
|
/** Deve corrispondere al `source` del rewrite in vercel.json. */
|
|
15
15
|
export const BOTID_CHALLENGE = '/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js';
|
|
16
|
+
const isRedirect = (status) => status >= 300 && status < 400;
|
|
17
|
+
const MAX_REDIRECTS = 5;
|
|
16
18
|
/** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
|
|
17
19
|
export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
|
|
18
20
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
19
21
|
try {
|
|
20
|
-
|
|
22
|
+
const response = await get(baseUrl);
|
|
23
|
+
if (response.ok || isRedirect(response.status))
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
catch { }
|
|
@@ -25,6 +28,25 @@ export async function waitForAlias({ get, baseUrl }, sleep, attempts = 5) {
|
|
|
25
28
|
await sleep(attempt * 2000);
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
/** La radice può essere un redirect, come il 302 di lingua di Astro, che non porta la CSP delle pagine renderizzate. */
|
|
32
|
+
async function landingPage(get, baseUrl) {
|
|
33
|
+
const { origin } = new URL(baseUrl);
|
|
34
|
+
let url = baseUrl;
|
|
35
|
+
for (let hops = 0;; hops++) {
|
|
36
|
+
const response = await get(url);
|
|
37
|
+
if (!isRedirect(response.status))
|
|
38
|
+
return { url, response };
|
|
39
|
+
if (hops === MAX_REDIRECTS)
|
|
40
|
+
throw new Error(`più di ${MAX_REDIRECTS} redirect a partire da ${baseUrl}`);
|
|
41
|
+
const location = response.headers.get('location');
|
|
42
|
+
if (location === null)
|
|
43
|
+
throw new Error(`${url} risponde ${response.status} senza location`);
|
|
44
|
+
const next = new URL(location, url);
|
|
45
|
+
if (next.origin !== origin)
|
|
46
|
+
throw new Error(`${url} rimanda a ${next.href}, fuori da ${origin}`);
|
|
47
|
+
url = next.href;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
28
50
|
export async function checkPages({ get, baseUrl }, pages) {
|
|
29
51
|
const results = [];
|
|
30
52
|
for (const { path, type } of pages) {
|
|
@@ -46,15 +68,17 @@ export async function checkPages({ get, baseUrl }, pages) {
|
|
|
46
68
|
return results;
|
|
47
69
|
}
|
|
48
70
|
export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
49
|
-
let
|
|
71
|
+
let landing;
|
|
50
72
|
try {
|
|
51
|
-
|
|
73
|
+
landing = await landingPage(get, baseUrl);
|
|
52
74
|
}
|
|
53
75
|
catch (error) {
|
|
54
76
|
return [fail('header di sicurezza', messageOf(error))];
|
|
55
77
|
}
|
|
78
|
+
const { url, response } = landing;
|
|
79
|
+
const where = url === baseUrl ? '' : ` su ${new URL(url).pathname}`;
|
|
56
80
|
const results = Object.entries(securityHeaders).map(([header, expected]) => {
|
|
57
|
-
const check = `header ${header}`;
|
|
81
|
+
const check = `header ${header}${where}`;
|
|
58
82
|
const value = response.headers.get(header);
|
|
59
83
|
if (value === null)
|
|
60
84
|
return fail(check, 'assente');
|
|
@@ -65,11 +89,13 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
|
65
89
|
// Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
|
|
66
90
|
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
67
91
|
const robots = response.headers.get('x-robots-tag');
|
|
68
|
-
results.push(robots === null ? pass(check) : fail(check, `presente su ${
|
|
92
|
+
results.push(robots === null ? pass(check) : fail(check, `presente su ${url}: "${robots}"`));
|
|
69
93
|
return results;
|
|
70
94
|
}
|
|
71
|
-
export async function checkBotIdChallenge({ get, baseUrl }) {
|
|
95
|
+
export async function checkBotIdChallenge({ get, baseUrl, botIdRequired }) {
|
|
72
96
|
const check = 'challenge di BotID servita dalla stessa origine';
|
|
97
|
+
if (!botIdRequired)
|
|
98
|
+
return [skip(check, 'spento da `features.botId: false`')];
|
|
73
99
|
try {
|
|
74
100
|
const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
|
|
75
101
|
if (response.status !== 200)
|
package/package.json
CHANGED