@elia97/officina 0.11.0 → 0.13.0

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.
Files changed (45) hide show
  1. package/README.md +92 -12
  2. package/dist/bin.js +4 -1
  3. package/dist/checks/bundle.js +13 -2
  4. package/dist/checks/doctor.js +13 -10
  5. package/dist/checks/migrate.d.ts +2 -0
  6. package/dist/checks/migrate.js +44 -0
  7. package/dist/checks/placeholders.js +15 -6
  8. package/dist/checks/secrets.d.ts +2 -0
  9. package/dist/checks/secrets.js +46 -0
  10. package/dist/checks/smoke.js +20 -4
  11. package/dist/index.d.ts +1 -0
  12. package/dist/lib/alignment.d.ts +1 -0
  13. package/dist/lib/alignment.js +4 -2
  14. package/dist/lib/bundle-css.d.ts +7 -0
  15. package/dist/lib/bundle-css.js +19 -0
  16. package/dist/lib/check-placeholders.d.ts +3 -1
  17. package/dist/lib/check-placeholders.js +9 -3
  18. package/dist/lib/check-routes.js +5 -3
  19. package/dist/lib/cli.d.ts +1 -0
  20. package/dist/lib/cli.js +1 -0
  21. package/dist/lib/config-gaps.js +1 -6
  22. package/dist/lib/config.d.ts +26 -1
  23. package/dist/lib/config.js +28 -2
  24. package/dist/lib/database-auth.d.ts +5 -0
  25. package/dist/lib/database-auth.js +81 -0
  26. package/dist/lib/env-schema.d.ts +8 -0
  27. package/dist/lib/env-schema.js +37 -0
  28. package/dist/lib/migrate.d.ts +11 -0
  29. package/dist/lib/migrate.js +29 -0
  30. package/dist/lib/routes.js +5 -3
  31. package/dist/lib/secrets.d.ts +10 -0
  32. package/dist/lib/secrets.js +53 -0
  33. package/dist/lib/smoke-on-demand.d.ts +8 -0
  34. package/dist/lib/smoke-on-demand.js +65 -0
  35. package/dist/lib/smoke-production.d.ts +4 -1
  36. package/dist/lib/smoke-production.js +2 -0
  37. package/dist/lib/validate.d.ts +2 -0
  38. package/dist/lib/validate.js +9 -1
  39. package/dist/lib/vercel-build.js +0 -1
  40. package/dist/lib/workflows.d.ts +14 -0
  41. package/dist/lib/workflows.js +44 -3
  42. package/dist/smoke.d.ts +1 -0
  43. package/dist/smoke.js +1 -0
  44. package/package.json +1 -1
  45. package/presets/biome.base.json +2 -1
package/README.md CHANGED
@@ -20,6 +20,7 @@ pnpm add -D @elia97/officina
20
20
  "check:placeholders": "officina check placeholders",
21
21
  "perf:bundle": "officina check bundle",
22
22
  "check:links": "officina check links",
23
+ "check:secrets": "officina check secrets",
23
24
  "smoke:prod": "officina check smoke",
24
25
  "analytics:verify": "officina check analytics",
25
26
  "lhci": "officina check lighthouse",
@@ -39,15 +40,16 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
39
40
 
40
41
  `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.
41
42
 
42
- `check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json`. Un link che esce dal repository è un rimando morto, perché in CI la cartella sopra non c'è. Restano fuori gli altri nomi senza cartella, come `head.astro`, gli URL, le ancore, il codice negli span e nei blocchi recintati, un nome che fa da testo a un link, dove conta la destinazione, i link e i nomi dei Markdown di `src/content/`, che sono copy del sito, e i nomi citati in `docs/ROADMAP.md` e nei `CHANGELOG.md`, che raccontano documenti usciti o da scrivere. Un blocco rientrato di quattro spazi non si riconosce come codice: un esempio va in un blocco recintato.
43
+ `check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `drizzle`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json`, più `drizzle.config.ts` dei progetti con un database. Un link che esce dal repository è un rimando morto, perché in CI la cartella sopra non c'è. Restano fuori gli altri nomi senza cartella, come `head.astro`, gli URL, le ancore, il codice negli span e nei blocchi recintati, un nome che fa da testo a un link, dove conta la destinazione, i link e i nomi dei Markdown di `src/content/`, che sono copy del sito, e i nomi citati in `docs/ROADMAP.md` e nei `CHANGELOG.md`, che raccontano documenti usciti o da scrivere. Un blocco rientrato di quattro spazi non si riconosce come codice: un esempio va in un blocco recintato.
43
44
 
44
45
  ## Verifiche di build e di produzione
45
46
 
46
47
  | Comando | Cosa guarda | Da dove prende le rotte |
47
48
  |---|---|---|
48
- | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages`; sotto SSR, il manifest della build Vercel |
49
+ | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta; nel CSS, le media query in sintassi di intervallo | `src/pages`; sotto SSR, il manifest della build Vercel |
49
50
  | `check links` | gli `<a href>` di `dist/client`: ogni link interno porta a qualcosa che la build serve, e nessuno a `#` | i file di `dist/client` e le rotte a richiesta del manifest della build Vercel |
50
- | `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
51
+ | `check secrets [--canaries]` | i file di `dist/client`: nessuno contiene il valore o il canary di una chiave server e secret di `env.schema` | — |
52
+ | `check smoke [url]` | la produzione viva: pagine, pagine rese a richiesta, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
51
53
  | `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
52
54
  | `check lighthouse [url] [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; con `url` misura un sito già servito, `--local` fa build, server statico e Chrome da sé | `src/pages` |
53
55
  | `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
@@ -56,12 +58,28 @@ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`
56
58
 
57
59
  Il CSS di una rotta è la somma dei fogli di `dist/client/_astro` che collega, ognuno contato una volta: per una pagina statica i `<link rel="stylesheet">` del suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query; per una rotta renderizzata a richiesta i fogli esterni che il manifest le assegna, e una rotta per cui il manifest non li dice è un fallimento. Il tetto è `bundle.cssMaxGzip`, e il rapporto ha una riga per ogni combinazione di fogli, perché le rotte che collegano gli stessi fogli pesano uguale. Non si contano il CSS dentro l'HTML, nei `<style>`, come il JavaScript incorporato, i link che non bloccano il rendering (`preload`, `prefetch`) e i fogli fuori da `_astro`, da `public/` o da un CDN. Un foglio citato che in `_astro` non c'è è un fallimento. Un foglio che nessuna rotta misurata collega si pesa da solo contro lo stesso tetto, e nel rapporto porta «da solo»: il CSS di un `import()` dinamico, quello dei componenti dentro un contenuto MDX reso a richiesta, che il manifest non assegna alla pagina, o tutti i fogli di una build le cui rotte non si leggono. Da solo pesa meno del vero, ma nessun foglio oltre il tetto passa in silenzio.
58
60
 
61
+ Il CSS costruito non deve contenere media query in sintassi di intervallo, come `@media (width>=40rem)`. Safari le legge solo dalla 16.4, e un Safari più vecchio ignora la regola intera: un sito scritto mobile-first si impagina come su un telefono. Le lascia Lightning CSS, che minifica il CSS della build, quando non sa per quali browser scrive: Astro 7 costruisce con `build.target: 'esnext'`, Vite 8 ne ricava `cssTarget`, e il minificatore non abbassa la sintassi e toglie anche i prefissi, come `-webkit-backdrop-filter`. Nessun browser recente mostra il difetto, e `astro dev` non minifica: si vede solo nel CSS costruito. `check bundle` legge i fogli di `dist/client/_astro` e i `<style>` dell'HTML delle pagine, dove Astro mette i fogli piccoli, e fallisce su ogni media query con un confronto nel preludio, `<`, `>` o `=`, i segni che soltanto la sintassi di intervallo usa; i commenti non contano, e il rapporto nomina il file e le query. Restano fuori le query di `@container`, i fogli fuori da `_astro`, i `<style>` delle pagine rese a richiesta, che nella build non hanno HTML, e i prefissi tolti, che il controllo non vede.
62
+
63
+ Il controllo non si spegne, e fallisce dalla versione che lo porta, senza un giro di avvisi: un progetto senza `cssTarget` ha già il difetto, e la correzione è una riga in `astro.config.mjs`, con i browser che il progetto sostiene. `min-width` e `max-width` sono la stessa query in ogni browser, quindi abbassare non costa niente.
64
+
65
+ ```js
66
+ export default defineConfig({
67
+ vite: { build: { cssTarget: ['chrome111', 'edge111', 'firefox114', 'safari15.4', 'ios15.4'] } },
68
+ })
69
+ ```
70
+
59
71
  `check links` legge gli `<a href>` di ogni HTML di `dist/client`, fuori dai commenti, dagli `<script>`, dagli `<style>` e dai valori degli attributi, dove Astro lascia `<` e `>` come sono. È interno un link relativo, uno che comincia con `/` o uno assoluto verso l'origine di `siteUrl`, e se ne confronta il percorso, senza query né ancora, con o senza barra finale; un link relativo si risolve come con `trailingSlash: 'never'`, che è quello dei template. Vale se porta a un file della build, pagina o altro file, o a una rotta che il manifest della build Vercel rende a richiesta, endpoint e `/` di lingua compresi, confrontata con il pattern che Astro scrive lì; senza manifest, o con un altro adapter, a una pagina `.astro` con `prerender = false`, e il rapporto lo dice. Che la pagina dietro una rotta a richiesta esista lo sa solo il server, e una rotta come `/[...slug]` accetta ogni percorso: il rapporto conta i link che passano così. `href="#"`, `href=""` e un `href` senza valore sono segnaposto, e falliscono come un link rotto. Il rapporto raggruppa per destinazione e nomina le pagine che la citano: un link rotto nel footer è un problema solo, non uno per pagina. Restano fuori, e il rapporto li conta, i link esterni, che chiederebbero la rete, `mailto:`, `tel:` e gli altri schemi, e l'id a cui punta un'ancora. Una `dist/client` senza HTML è un fallimento, perché il controllo non affermerebbe niente: un progetto che rende ogni pagina a richiesta lo spegne con `features.links: false`. Due limiti: con una `base` i link non si risolvono, e un redirect di `vercel.json` non è una pagina, quindi un link che ci porta passa per rotto, e si corregge puntando alla destinazione.
60
72
 
73
+ `check secrets` legge le chiavi di `env.schema` con `context: 'server'` e `access: 'secret'`, dal sorgente di `astro.config.mjs` come `doctor`, e le cerca in ogni file di `dist/client`: un import sbagliato basta a portare un segreto nel browser. Per ogni chiave cerca il valore che ha nell'ambiente, anche nelle forme in cui Astro lo scrive nel markup e negli script, e il suo canary, `officina-<CHIAVE>-canary`, che `--canaries` stampa come righe `CHIAVE=valore` da mettere nell'ambiente della build. `actions/ci` lo fa dopo `pnpm run ci`, così i test che `ci` lancia non vedono i canary e la build sì. Il canary si dà solo ai campi `envField.string` senza validazioni (`max`, `min`, `length`, `url`, `includes`, `startsWith`, `endsWith`), perché Astro valida ogni variabile sul suo tipo e su quelle, e un canary che non le rispetta fermerebbe la build: una chiave così, in CI, resta da verificare. Una chiave che compare in un file è un fallimento che nomina la chiave e il file. Una chiave senza valore né canary nell'ambiente, o con un valore di meno di 8 caratteri, che cercato darebbe falsi positivi, non si verifica: il rapporto la dice con un avviso, invece di contarla fra le verificate. Uno schema senza chiavi segrete non ha niente da verificare, e una `dist/client` vuota è un fallimento.
74
+
61
75
  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`.
62
76
 
63
77
  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.
64
78
 
79
+ Lo smoke visita anche le pagine rese a richiesta, quelle di `src/pages` con `export const prerender = false`. Ognuna risponde 200, oppure con un redirect sulla stessa origine, come una pagina protetta che rimanda all'accesso; un 200 è HTML e porta il meta `Content-Security-Policy` con la direttiva `script-src`, quello che il middleware dei template scrive nelle pagine rese a richiesta. Una pagina dinamica si visita col suo rappresentante di `routes.representatives`, altrimenti si salta dicendolo. Queste pagine non entrano nel controllo della barra finale né in Lighthouse. Nella 0.12.0 e nella 0.13.0 un'anomalia è un avviso, che lo smoke stampa con `·` senza fallire; dalla 0.14.0 sarà un fallimento. officina riconosce una pagina resa a richiesta dalla dichiarazione: con `output: 'server'`, una pagina senza `prerender = false` resta per tutti i gate una pagina prerenderizzata, e lo smoke la visita come le altre, senza cercarne la CSP.
80
+
81
+ Quando fallisce, lo smoke dice che la produzione è online e rotta, e consiglia di tornare indietro dalla dashboard di Vercel promuovendo l'ultimo deployment di produzione sano. Con `features.database` accesa aggiunge che lo schema non torna indietro: le migrazioni già applicate restano, e il deployment promosso regge solo se sono compatibili con il suo codice, la regola di «Migrazioni compatibili con la produzione» in § Action per i workflow.
82
+
65
83
  Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
66
84
 
67
85
  ```ts
@@ -87,7 +105,7 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
87
105
 
88
106
  | Sottopercorso | Cosa esporta |
89
107
  |---|---|
90
- | `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i cinque controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
108
+ | `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i sei controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
91
109
  | `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
92
110
  | `@elia97/officina/icons` | `ICON_SPECS` |
93
111
 
@@ -99,9 +117,11 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
99
117
 
100
118
  `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.
101
119
 
102
- `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.
120
+ `features.database` e `features.auth` sono le due voci che il silenzio non accende, perché la maggior parte dei progetti un database non lo ha: seguono le dipendenze. `database` è accesa quando `package.json` dipende da `drizzle-orm`, `auth` quando dipende da `better-auth`, fra le `dependencies` o le `devDependencies`; senza la libreria la voce non si chiede e vale spenta. Dichiarata, vale la dichiarazione: `false` spegne la feature anche con la libreria, e `doctor` lo scrive nell'uscita; `'required'` la accende anche senza. `database.migrationUrlKey` è la variabile con cui girano le migrazioni, `DATABASE_URL_UNPOOLED` se non è dichiarata, e deve essere il nome di una variabile d'ambiente.
121
+
122
+ `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. Con il database acceso `--env` pretende anche `DATABASE_URL`, e con l'autenticazione `BETTER_AUTH_SECRET`, qualunque sia `contactEnvKeys`: la variabile delle migrazioni no, perché al deploy arriva da un segreto di GitHub.
103
123
 
104
- 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.
124
+ 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 **Secret**: `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 Config su Vercel, o letta a runtime, e una Secret non diventa Config: si toglie e si riaggiunge.
105
125
 
106
126
  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.
107
127
 
@@ -132,15 +152,20 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
132
152
 
133
153
  | Action | Passi | Cosa resta nel workflow del progetto |
134
154
  |---|---|---|
135
- | `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`, `check:links`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
155
+ | `actions/ci` | checkout, node, install, `astro sync`, con `migrate` le migrazioni del database della corsa, `pnpm run ci`, i canary di `check secrets`, build, `perf:bundle`, `check:links`, `check:secrets`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci`; con un database, il branch Neon della corsa, creato e cancellato intorno all'action, e lo script delle migrazioni e il suo indirizzo passati in `with:` |
136
156
  | `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
137
- | `actions/deploy` | risoluzione del tag, stessa versione di officina, 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 |
157
+ | `actions/deploy` | risoluzione del tag, stessa versione di officina, gate, `vercel pull`, con `migrate` le migrazioni, `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; con un database, lo script delle migrazioni e il segreto `PRODUCTION_DATABASE_URL` passati in `with:` |
138
158
  | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
139
159
 
140
160
  ```yaml
161
+ concurrency:
162
+ group: deploy-production
163
+ cancel-in-progress: false
164
+
141
165
  jobs:
142
166
  deploy:
143
167
  runs-on: ubuntu-latest
168
+ timeout-minutes: 30
144
169
  environment:
145
170
  name: production
146
171
  url: ${{ steps.deploy.outputs.url }}
@@ -156,17 +181,70 @@ jobs:
156
181
 
157
182
  `astro sync` genera i tipi di `.astro/`, che fallow pretende e un checkout pulito non ha; se fallisce il passo lo segnala con un avviso e il job prosegue.
158
183
 
159
- I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy.
184
+ I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`, come `database-url` in quello delle migrazioni. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy o il database di produzione.
160
185
 
161
186
  **Il deploy lanciato a mano.** Con `workflow_dispatch` il workflow viene dal ramo scelto in «Use workflow from», e il pacchetto dal lockfile del tag che si deploya: le due metà possono avere due versioni di officina diverse, e un passo dell'action più nuova può chiamare un'opzione che gli script del tag non conoscono. `actions/deploy` confronta le due versioni subito dopo l'install, prima di ogni script del progetto e di `vercel pull`, e si ferma dicendole se non coincidono o se il tag non installa officina. Un tag più vecchio si deploya lanciando Deploy da quel tag: il suo `deploy.yml` dichiara l'action della sua versione, con i gate di allora. Perché si possa, l'environment `production` del progetto deve ammettere i tag fra i ref da cui si deploya.
162
187
 
188
+ **Il database di test.** Ogni corsa della CI migra un database suo: un branch Neon che il `ci.yml` del progetto crea prima di `actions/ci`, figlio di un branch vuoto, e cancella alla fine. Così due corse in parallelo, come la CI di una PR e quella di `main`, non vedono l'una le migrazioni dell'altra. Il genitore resta vuoto perché il migratore di drizzle-orm 0.45 applica una migrazione solo se è più recente dell'ultima applicata: ogni corsa le applica tutte, da zero, e nessuna si salta.
189
+
190
+ ```yaml
191
+ - name: Branch Neon della corsa
192
+ id: neon
193
+ env:
194
+ NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
195
+ run: |
196
+ [ -n "$NEON_API_KEY" ] || exit 0
197
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
198
+ echo "expires=$(date -u -d '+2 hours' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
199
+ - uses: neondatabase/create-branch-action@v6
200
+ id: branch
201
+ if: steps.neon.outputs.enabled == 'true'
202
+ with:
203
+ project_id: ${{ vars.NEON_PROJECT_ID }}
204
+ api_key: ${{ secrets.NEON_API_KEY }}
205
+ parent_branch: test
206
+ branch_name: ci-${{ github.run_id }}-${{ github.run_attempt }}
207
+ expires_at: ${{ steps.neon.outputs.expires }}
208
+ - uses: Elia97/officina/actions/ci@vX.Y.Z
209
+ with:
210
+ migrate: db:migrate
211
+ database-url: ${{ steps.branch.outputs.db_url }}
212
+ - uses: neondatabase/delete-branch-action@v3
213
+ if: always() && steps.branch.outputs.branch_id != ''
214
+ with:
215
+ project_id: ${{ vars.NEON_PROJECT_ID }}
216
+ branch: ${{ steps.branch.outputs.branch_id }}
217
+ api_key: ${{ secrets.NEON_API_KEY }}
218
+ ```
219
+
220
+ Il genitore, `test` nell'esempio, è il branch di test di prima. Si svuota una volta con `DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;`, e da lì nessuno lo migra. Il primo passo legge la chiave dal proprio `env`, non da quello del job, dove la vedrebbero anche le dipendenze. `expires_at` cancella il branch anche quando il passo finale non arriva. La chiave dell'API di Neon crea e cancella i branch del progetto, quindi va limitata al progetto dove Neon lo permette, e non va data a Dependabot, che la passerebbe al codice di ogni aggiornamento. `doctor` avvisa quando `ci.yml` passa ancora un branch condiviso, `TEST_DATABASE_URL`.
221
+
222
+ Prima di `pnpm run ci` l'action lancia `officina migrate --script <migrate>`, che legge `database.migrationUrlKey` da `officina.config.ts` e dà l'indirizzo allo script sotto quella chiave, e solo sotto quella. `pnpm run ci` lo riceve come `TEST_DATABASE_URL`, per i test di integrazione. La build e `check:secrets` lo ricevono come `DATABASE_URL`, al posto del canary: una pagina prerenderizzata dal database lo legge davvero, e `check secrets` cerca il valore che la build ha ricevuto. Gli altri passi non lo vedono, e senza `database-url` i comandi ricevono l'ambiente del job com'è. Le PR di Dependabot e quelle da un fork non ricevono `NEON_API_KEY`: il branch non nasce, `database-url` arriva vuoto, e con `migrate` l'action non migra, lo dice con un `::notice::` e prosegue. I test di integrazione si saltano da soli, e la build riceve il canary, quindi una pagina prerenderizzata dal database non ha un database da leggere, e la build si ferma.
223
+
224
+ **Le migrazioni al deploy.** Con un database, anche `actions/deploy` riceve lo script delle migrazioni, con l'indirizzo di produzione:
225
+
226
+ ```yaml
227
+ - uses: Elia97/officina/actions/deploy@vX.Y.Z
228
+ with:
229
+ migrate: db:migrate
230
+ database-url: ${{ secrets.PRODUCTION_DATABASE_URL }}
231
+ ```
232
+
233
+ Dopo `vercel pull` e `check:placeholders --env`, subito prima di `vercel build`, l'action lancia `officina migrate --production --script <migrate>`, che dà l'indirizzo allo script sotto `database.migrationUrlKey`, e solo sotto quella. `PRODUCTION_DATABASE_URL` è un segreto dell'environment `production` del repository, che il job di deploy dichiara: lo leggono solo i job di quell'environment, e solo dai ref che le sue regole ammettono, da restringere a `main` e ai tag di release, perché senza regole lo legge un job su qualunque branch. È l'indirizzo diretto al database, senza pooler, come `DATABASE_URL_UNPOOLED` su Neon. Senza `database-url` il passo delle migrazioni si ferma, e il deploy con lui. `doctor` avvisa anche quando il deploy riceve `TEST_DATABASE_URL`, o la CI `PRODUCTION_DATABASE_URL`: le migrazioni andrebbero sul database sbagliato, uscendo 0. Il runner maschera il segreto intero, ma non la password da sola: il comando registra con `::add-mask::` l'indirizzo e la sua password, com'è e decodificata, su stdout e su stderr, e lancia lo script quando le maschere sono scritte.
234
+
235
+ **Il segreto delle migrazioni.** Su Vercel la chiave delle migrazioni non serve, e se l'applicazione la usa a runtime può restare Secret. Una risorsa del Marketplace impostata su «Production only», che protegge le sue credenziali come Secret, non ferma più le migrazioni. Rende però Secret anche `DATABASE_URL`, e una build che legge il database la riceve come `[SENSITIVE]` e si ferma, a migrazioni già applicate. Il prezzo è un secondo posto da tenere allineato: quando cambia l'indirizzo, che sia la password, l'host o il database, il segreto va aggiornato anche su GitHub. Con una password vecchia il deploy si ferma al passo delle migrazioni. Se invece il database vecchio risponde ancora, le migrazioni vanno lì, e il deploy non se ne accorge.
236
+
237
+ **Migrazioni compatibili con la produzione.** Le migrazioni si applicano prima della build: se poi la build o il deploy falliscono, la produzione resta sul codice di prima con lo schema nuovo, e lo schema non torna indietro né promuovendo il deployment precedente, quando fallisce lo smoke, né deployando un tag più vecchio. Ogni migrazione deve quindi reggere il codice in produzione, e additiva non basta: lo rompono, senza togliere niente, un `NOT NULL` senza default, un `UNIQUE`, un `CHECK` o una chiave esterna nuovi su dati che quel codice scrive, come un cambio di tipo o un vincolo più stretto. Ciò che toglie arriva dopo: una colonna si toglie quando nessun deployment che si potrebbe promuovere la legge più, e una rinomina passa da una colonna nuova, che il codice scrive insieme alla vecchia, prima di togliere la vecchia in una release successiva.
238
+
239
+ **Un tempo massimo.** Il passo delle migrazioni non ne ha uno, e il passo di una composite action non accetta `timeout-minutes`. Una migrazione che aspetta un lock blocca, finché non lo ottiene, le query di produzione su quella tabella: le migrazioni del progetto chiedono un `lock_timeout`, con `SET lock_timeout` di Postgres in testa al file, e il job di deploy un `timeout-minutes`, come nell'esempio del deploy.
240
+
163
241
  Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
164
242
 
165
243
  Perché le action non restino ferme per sempre, Dependabot di questo repository copre l'ecosistema `github-actions` anche dentro `/actions/*`, dove le action di terze parti sono fissate allo SHA intero con la versione nel commento.
166
244
 
167
245
  ## doctor
168
246
 
169
- `officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
247
+ `officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts` e, con un database o l'autenticazione, ciò che li rende sicuri. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
170
248
 
171
249
  Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso. Le action le cerca con il nome che dà loro Dependabot, quello del repository senza la sottocartella: `Elia97/officina`. `Elia97/officina/*` non corrisponde a nessuna dipendenza, in `patterns` come in `ignore` o in `exclude-patterns`, e `doctor` lo segnala ovunque compaia.
172
250
 
@@ -183,7 +261,9 @@ Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
183
261
  - **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
184
262
  - **gli strumenti che preset e action danno per scontati**: `@biomejs/biome`, `lefthook`, `@commitlint/cli`, `@commitlint/config-conventional` e `fallow` fra le `devDependencies`, con la versione di Biome dentro l'intervallo che il preset dichiara di reggere.
185
263
 
186
- `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.
264
+ Con `features.database` o `features.auth` accese c'è una sezione in più, «database e autenticazione», che chiede ciò che rende sicuro un progetto con dati e utenti. Con il database: gli script `db:generate` e `db:migrate`, `drizzle.config.ts`, gli input `migrate` e `database-url` di `actions/ci` in `ci.yml` e di `actions/deploy` in `deploy.yml`. Per ogni feature accesa, la sua chiave in `env.schema`, `DATABASE_URL` o `BETTER_AUTH_SECRET`, con `context: 'server'` e `access: 'secret'`: una variabile pubblica Astro la scrive nel codice della build. Lo schema si legge dal sorgente di `astro.config.mjs`, senza eseguirlo, quindi una chiave conta se è scritta lì con `context` e `access` per esteso.
265
+
266
+ `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`. Nei workflow i riferimenti alle action si cercano nel testo, perché la versione di uno SHA sta nel commento, e una riga commentata non soddisfa nessun controllo; gli input che un passo dà a un'action si leggono come YAML.
187
267
 
188
268
  **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. L'avviso serve a far passare quella PR, e dove non può passare comunque il requisito nasce come errore: con il filtro `Elia97/officina/*` l'aggiornamento arriva diviso in due PR, e nessuna delle due passa il controllo sulla stessa versione.
189
269
 
@@ -235,4 +315,4 @@ I test dei generatori girano sui punti di aggancio del progetto di prova, copiat
235
315
 
236
316
  ## Cosa arriverà
237
317
 
238
- I preset di `ecommerce` e `monorepo`.
318
+ I preset di `monorepo`.
package/dist/bin.js CHANGED
@@ -12,6 +12,7 @@ export const CHECKS = {
12
12
  placeholders: () => import('./checks/placeholders.js'),
13
13
  roadmap: () => import('./checks/roadmap.js'),
14
14
  routes: () => import('./checks/routes.js'),
15
+ secrets: () => import('./checks/secrets.js'),
15
16
  smoke: () => import('./checks/smoke.js'),
16
17
  'vercel-cli': () => import('./checks/vercel-cli.js'),
17
18
  };
@@ -20,9 +21,10 @@ const USAGE = `Uso:
20
21
  officina gen [generatore] section, page, component, collection, più quelli del progetto
21
22
  officina gen icons le icone del manifest da public/favicon.svg
22
23
  officina doctor cosa manca al progetto perché i generatori funzionino
24
+ officina migrate --script <nome> le migrazioni sotto database.migrationUrlKey: da TEST_DATABASE_URL, o con --production da PRODUCTION_DATABASE_URL
23
25
 
24
26
  Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
25
- bundle e links leggono dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
27
+ bundle, links e secrets leggono dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
26
28
  I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
27
29
  `;
28
30
  // Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
@@ -32,6 +34,7 @@ const GROUPS = {
32
34
  ? { load: () => import('./gen/icons.js'), args: args.slice(1) }
33
35
  : { load: () => import('./gen/run.js'), args },
34
36
  doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
37
+ migrate: (args) => ({ load: () => import('./checks/migrate.js'), args }),
35
38
  };
36
39
  export async function run(argv) {
37
40
  const [group = '', ...rest] = argv;
@@ -5,7 +5,7 @@ import { join, relative } from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { gzipSync } from 'node:zlib';
7
7
  import { budgetFor, CSS_BUDGET_GZIP, deferredClosure, htmlEntries, htmlStylesheets, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
8
- import { measureCss, routeCount } from '../lib/bundle-css.js';
8
+ import { inlineStyles, measureCss, rangeQueryFailures, routeCount } from '../lib/bundle-css.js';
9
9
  import { readSsr } from '../lib/bundle-ssr.js';
10
10
  import { missingInput } from '../lib/cli.js';
11
11
  import { loadConfig } from '../lib/config.js';
@@ -89,6 +89,17 @@ function checkCss(pages, cssMaxGzip, width) {
89
89
  printCss(css, cssMaxGzip, width);
90
90
  return css.failures;
91
91
  }
92
+ function checkRangeSyntax() {
93
+ const sheets = filesWithExtension(ASSETS, '.css').map((file) => ({ file, css: readFileSync(file, 'utf8') }));
94
+ const pages = filesWithExtension(DIST, '.html').map((file) => ({
95
+ file,
96
+ css: inlineStyles(readFileSync(file, 'utf8')),
97
+ }));
98
+ const failures = rangeQueryFailures([...sheets, ...pages]);
99
+ if (failures.length === 0)
100
+ console.log('\nMedia query in sintassi di intervallo: nessuna.');
101
+ return failures;
102
+ }
92
103
  function printSsrNote(expected, ssr) {
93
104
  for (const note of ssr.notes)
94
105
  console.log(`\nNOTA ${note}`);
@@ -124,7 +135,7 @@ export async function main() {
124
135
  failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
125
136
  const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
126
137
  printPages(pages, width);
127
- failures.push(...checkCss(routes, cssMaxGzip, width));
138
+ failures.push(...checkCss(routes, cssMaxGzip, width), ...checkRangeSyntax());
128
139
  printSsrNote(expected, ssr);
129
140
  printDisabled(expected);
130
141
  if (failures.length > 0) {
@@ -3,12 +3,13 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { PROJECT_GENERATORS } from '../gen/plopfile.mjs';
6
- import { dependencyGaps, leftoverGaps, presetGaps, scriptGaps, } from '../lib/alignment.js';
6
+ import { dependencyGaps, leftoverGaps, presetGaps, readManifest, scriptGaps, } from '../lib/alignment.js';
7
7
  import { anchorGaps } from '../lib/anchors.js';
8
8
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
9
- import { findConfigFile, generatorsSetting, loadConfig, } from '../lib/config.js';
9
+ import { featuresOffByChoice, featuresOn, findConfigFile, generatorsSetting, loadConfig, } from '../lib/config.js';
10
10
  import { configGaps } from '../lib/config-gaps.js';
11
11
  import { contractGaps } from '../lib/contract.js';
12
+ import { databaseAuthGaps } from '../lib/database-auth.js';
12
13
  import { dependabotGaps } from '../lib/dependabot.js';
13
14
  import { trackedAndUntracked } from '../lib/git.js';
14
15
  import { expectedRoutes, missingRepresentatives, readPageFiles, unknownDisabled } from '../lib/routes.js';
@@ -22,14 +23,6 @@ function projectFiles(root) {
22
23
  read: (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined),
23
24
  };
24
25
  }
25
- function readManifest(files) {
26
- try {
27
- return JSON.parse(files.read('package.json') ?? '{}');
28
- }
29
- catch {
30
- return {};
31
- }
32
- }
33
26
  async function loadedConfig(root) {
34
27
  if (findConfigFile(root) === undefined)
35
28
  return undefined;
@@ -81,6 +74,15 @@ function configSection(root, config) {
81
74
  const notes = disabled.length === 0 ? [] : [`spente da \`routes.disabled\`: ${disabled.join(', ')}`];
82
75
  return { title: 'officina.config.ts', gaps: configGaps(config, gaps), notes };
83
76
  }
77
+ const DATABASE_AUTH = 'database e autenticazione';
78
+ function databaseAuthSections(files, config, manifest) {
79
+ const on = featuresOn(config, manifest);
80
+ const off = featuresOffByChoice(config, manifest).map((name) => `\`features.${name}: false\``);
81
+ if (on.length === 0)
82
+ return off.length === 0 ? [] : [{ title: DATABASE_AUTH, off: off.join(' e ') }];
83
+ const notes = off.map((setting) => `spenta da ${setting}`);
84
+ return [{ title: DATABASE_AUTH, gaps: databaseAuthGaps(files, manifest, on), notes }];
85
+ }
84
86
  export async function main() {
85
87
  const root = process.cwd();
86
88
  const files = projectFiles(root);
@@ -100,6 +102,7 @@ export async function main() {
100
102
  gaps: [...workflowGaps(files, version), ...dependabotGaps(files)],
101
103
  },
102
104
  configSection(root, config),
105
+ ...databaseAuthSections(files, usable(config), manifest),
103
106
  ];
104
107
  console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
105
108
  const findings = [];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): Promise<number>;
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import process from 'node:process';
4
+ import { parseArgs } from 'node:util';
5
+ import { maskCommand } from '../lib/cli.js';
6
+ import { loadConfig, migrationUrlKey } from '../lib/config.js';
7
+ import { maskedValues, migrationEnv } from '../lib/migrate.js';
8
+ const written = (stream, text) => new Promise((resolve, reject) => {
9
+ stream.write(text, (error) => (error ? reject(error) : resolve()));
10
+ });
11
+ export async function main(args = process.argv.slice(2)) {
12
+ const options = { script: { type: 'string' }, production: { type: 'boolean' } };
13
+ const { script, production } = parseArgs({ args, options }).values;
14
+ if (!script) {
15
+ console.error('\n✗ manca `--script`: il nome dello script delle migrazioni, per esempio `db:migrate`.\n');
16
+ return 1;
17
+ }
18
+ const key = migrationUrlKey(await loadConfig(process.cwd()));
19
+ const migration = migrationEnv(process.env, key, production ? 'production' : 'test');
20
+ if ('error' in migration) {
21
+ console.error(`\n✗ ${migration.error}.\n`);
22
+ return 1;
23
+ }
24
+ if (process.env.GITHUB_ACTIONS) {
25
+ // Su una pipe piena libuv scrive il resto solo dopo `spawnSync`; il runner legge stderr prima di stdout
26
+ // in ogni lotto di righe.
27
+ const masks = `${maskedValues(migration.url).map(maskCommand).join('\n')}\n`;
28
+ await Promise.all([written(process.stdout, masks), written(process.stderr, masks)]);
29
+ }
30
+ const where = production ? 'in produzione' : 'sul branch di test';
31
+ console.log(`\nmigrate — \`pnpm run ${script}\` ${where}, con l'indirizzo in \`${key}\`\n`);
32
+ const { status, signal, error } = spawnSync('pnpm', ['run', script], { stdio: 'inherit', env: migration.env });
33
+ if (error !== undefined) {
34
+ console.error(`\n✗ pnpm non parte: ${error.message}.\n`);
35
+ return 1;
36
+ }
37
+ if (signal !== null) {
38
+ console.error(`\n✗ \`pnpm run ${script}\` si è fermato per ${signal}.\n`);
39
+ return 1;
40
+ }
41
+ return status ?? 1;
42
+ }
43
+ if (import.meta.main)
44
+ process.exit(await main());
@@ -2,9 +2,10 @@
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, outputFindings, placeholderSources, sourceFindings, } from '../lib/check-placeholders.js';
5
+ import { readManifest } from '../lib/alignment.js';
6
+ import { envFindings, outputFindings, placeholderSources, requiredEnvKeys, sourceFindings, } from '../lib/check-placeholders.js';
6
7
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
7
- import { loadConfig } from '../lib/config.js';
8
+ import { featuresOn, loadConfig } from '../lib/config.js';
8
9
  const MISSING_SOURCE = 'sorgente assente: `placeholders.sources` in officina.config.ts dice quali file guardare';
9
10
  function sourceScan(paths) {
10
11
  return paths.flatMap((path) => {
@@ -23,21 +24,29 @@ function outputScan(dir) {
23
24
  return [{ path: dir, message: 'cartella della build assente: la scrive vercel build', severity: 'error' }];
24
25
  return outputFindings(dir);
25
26
  }
27
+ const projectFile = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
28
+ const ENV_ADVICE = 'Da impostare su Vercel, fra le variabili di produzione, prima del deploy: quali pretendere lo dice `placeholders.contactEnvKeys` in officina.config.ts';
29
+ function envAdvice(on) {
30
+ if (on.length === 0)
31
+ return ENV_ADVICE;
32
+ return `${ENV_ADVICE}, più le chiavi che pretendono ${on.map((name) => `\`features.${name}\``).join(' e ')}`;
33
+ }
26
34
  function scan(env, output, config) {
27
35
  if (output !== undefined) {
28
36
  return {
29
37
  scope: `build in ${output}`,
30
38
  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',
39
+ clean: 'Nessuna variabile Secret entrata nella build.',
40
+ advice: 'Una variabile che la build legge non può essere Secret su Vercel: va tolta e riaggiunta come Config, o letta a runtime',
33
41
  };
34
42
  }
35
43
  if (env !== undefined) {
44
+ const manifest = readManifest({ read: projectFile });
36
45
  return {
37
46
  scope: `ambiente in ${env}`,
38
- findings: envScan(env, contactEnvKeys(config)),
47
+ findings: envScan(env, requiredEnvKeys(config, manifest)),
39
48
  clean: 'Nessun segnaposto del template.',
40
- advice: 'Da impostare su Vercel, fra le variabili di produzione, prima del deploy: quali pretendere lo dice `placeholders.contactEnvKeys` in officina.config.ts',
49
+ advice: envAdvice(featuresOn(config, manifest)),
41
50
  };
42
51
  }
43
52
  const paths = placeholderSources(config);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(args?: string[]): number;
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import process from 'node:process';
4
+ import { parseArgs } from 'node:util';
5
+ import { cliOptions, exitCode, missingInput, printFindings } from '../lib/cli.js';
6
+ import { envSchema } from '../lib/env-schema.js';
7
+ import { filesWithExtension } from '../lib/routes.js';
8
+ import { canaryLines, scanSecrets, secretFields } from '../lib/secrets.js';
9
+ const SCHEMA = 'astro.config.mjs';
10
+ const DIST = 'dist/client';
11
+ const ADVICE = 'Una chiave server e secret si legge solo nel codice che gira sul server: segui la strada che la porta nel file nominato';
12
+ function check(keys) {
13
+ const counted = keys.length === 1 ? '1 chiave' : `${keys.length} chiavi`;
14
+ console.log(`\ncheck:secrets — ${counted} server e secret di env.schema, cercate in ${DIST}\n`);
15
+ if (keys.length === 0) {
16
+ console.log(' Nessuna chiave segreta nello schema: niente da verificare.\n');
17
+ return 0;
18
+ }
19
+ if (missingInput(DIST, 'è la build in cui si cercano le chiavi: prima `pnpm build`'))
20
+ return 1;
21
+ const files = filesWithExtension(DIST, '');
22
+ if (files.length === 0) {
23
+ console.error(`\n✗ ${DIST} è vuota: senza file il controllo non afferma niente.\n`);
24
+ return 1;
25
+ }
26
+ const { findings, verified } = scanSecrets(keys, process.env, files, (path) => readFileSync(path));
27
+ printFindings(findings, cliOptions([]).format);
28
+ if (verified.length > 0)
29
+ console.log(` Verificate: ${verified.join(', ')}.`);
30
+ if (findings.some(({ severity }) => severity === 'error'))
31
+ console.log(`\n ${ADVICE}.\n`);
32
+ return exitCode(findings, false);
33
+ }
34
+ export function main(args = process.argv.slice(2)) {
35
+ const { canaries } = parseArgs({ args, options: { canaries: { type: 'boolean', default: false } } }).values;
36
+ if (missingInput(SCHEMA, 'le chiavi segrete stanno nel suo `env.schema`'))
37
+ return 1;
38
+ const secrets = secretFields(envSchema(readFileSync(SCHEMA, 'utf8')));
39
+ if (!canaries)
40
+ return check(secrets.map(({ key }) => key));
41
+ for (const line of canaryLines(secrets))
42
+ console.log(line);
43
+ return 0;
44
+ }
45
+ if (import.meta.main)
46
+ process.exit(main());
@@ -1,27 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  // L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
3
+ import { existsSync, readFileSync } from 'node:fs';
3
4
  import process from 'node:process';
5
+ import { readManifest } from '../lib/alignment.js';
4
6
  import { missingInput } from '../lib/cli.js';
5
- import { isRequired, loadConfig } from '../lib/config.js';
7
+ import { isOn, isRequired, loadConfig } from '../lib/config.js';
6
8
  import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
9
+ import { onDemandPages } from '../lib/smoke-on-demand.js';
7
10
  import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
8
11
  const PAGES_DIR = 'src/pages';
12
+ const projectFile = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
9
13
  function printResults(results) {
10
14
  for (const { check, status, detail } of results) {
11
15
  if (status === 'pass')
12
16
  console.log(`✓ ${check}`);
13
17
  else if (status === 'skip')
14
18
  console.log(`- ${check} (saltato: ${detail})`);
19
+ else if (status === 'warn')
20
+ console.log(`· ${check} — ${detail}`);
15
21
  else
16
22
  console.error(`✗ ${check} — ${detail}`);
17
23
  }
18
24
  }
19
25
  const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
20
- function printFailures(failures) {
26
+ function printFailures(failures, database) {
21
27
  console.error(`\n✗ ${failures.length} controllo/i falliti:`);
22
28
  console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
23
29
  console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
24
30
  console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
31
+ if (!database)
32
+ return;
33
+ console.error('Con un database lo schema non torna indietro: le migrazioni già applicate restano,');
34
+ console.error('e il deployment che promuovi regge solo se sono compatibili con il suo codice.');
35
+ console.error('La regola è nel README di officina: «Migrazioni compatibili con la produzione».\n');
25
36
  }
26
37
  export async function main(args = []) {
27
38
  const config = await loadConfig(process.cwd());
@@ -50,13 +61,18 @@ export async function main(args = []) {
50
61
  const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
51
62
  console.log(`\nSmoke di produzione — ${baseUrl}\n`);
52
63
  await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
53
- const results = await runChecks(context, pages, smoke.checks);
64
+ const onDemand = onDemandPages(expected, PAGES_DIR, routes.representatives);
65
+ const results = await runChecks({ ...context, onDemand }, pages, smoke.checks);
54
66
  printResults([...results, ...disabledResults(expected)]);
55
67
  const failures = results.filter(({ status }) => status === 'fail');
56
68
  if (failures.length > 0) {
57
- printFailures(failures);
69
+ printFailures(failures, isOn(config, readManifest({ read: projectFile }), 'database'));
58
70
  return 1;
59
71
  }
72
+ const warnings = results.filter(({ status }) => status === 'warn').length;
73
+ // TODO: dalla 0.14.0 le anomalie a richiesta sono `fail` (smoke-on-demand.ts): togli il log degli avvisi qui sotto.
74
+ if (warnings > 0)
75
+ console.log(`\n· ${warnings} avviso/i: dalla 0.14.0 fanno fallire lo smoke.`);
60
76
  console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
61
77
  return 0;
62
78
  }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export type { OfficinaConfig } from './lib/config.ts';
3
3
  export { defineConfig } from './lib/config.ts';
4
4
  export type { IconSpec } from './lib/icon-specs.ts';
5
5
  export type { VerifiedRoute } from './lib/routes.ts';
6
+ export type { OnDemandPage } from './lib/smoke-on-demand.ts';
6
7
  export type { CheckResult, SmokeCheck, SmokeContext } from './lib/smoke-production.ts';
@@ -13,6 +13,7 @@ export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
13
13
  export declare const CI_STEPS: readonly string[];
14
14
  export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
15
15
  export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
16
+ export declare const readManifest: (files: Pick<ProjectFiles, 'read'>) => Manifest;
16
17
  export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
17
18
  export declare const BIOME_PRESET = "@elia97/officina/biome";
18
19
  export declare const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";
@@ -7,6 +7,7 @@ export const EXPECTED_SCRIPTS = {
7
7
  'check:placeholders': 'officina check placeholders',
8
8
  'perf:bundle': 'officina check bundle',
9
9
  'check:links': 'officina check links',
10
+ 'check:secrets': 'officina check secrets',
10
11
  'smoke:prod': 'officina check smoke',
11
12
  'analytics:verify': 'officina check analytics',
12
13
  lhci: 'officina check lighthouse',
@@ -27,8 +28,8 @@ export const CI_STEPS = [
27
28
  'doctor',
28
29
  ];
29
30
  const MANIFEST = 'package.json';
30
- // TODO: dalla 0.12.0 `check:links` è un errore di doctor: toglilo da NEW_SCRIPTS.
31
- const NEW_SCRIPTS = new Set(['check:links']);
31
+ // TODO: dalla 0.14.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
32
+ const NEW_SCRIPTS = new Set(['check:secrets']);
32
33
  export function scriptGaps({ scripts = {} }) {
33
34
  const gaps = [];
34
35
  for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
@@ -114,6 +115,7 @@ function readJson({ read }, path) {
114
115
  return undefined;
115
116
  }
116
117
  }
118
+ export const readManifest = (files) => (readJson(files, MANIFEST) ?? {});
117
119
  export function leftoverGaps(files) {
118
120
  const gaps = LEFTOVERS.filter(({ path }) => holds(files.paths, path)).map(({ path, reason }) => ({
119
121
  path,
@@ -12,5 +12,12 @@ export type CssMeasure = {
12
12
  groups: CssGroup[];
13
13
  failures: string[];
14
14
  };
15
+ export type CssSource = {
16
+ file: string;
17
+ css: string;
18
+ };
15
19
  export declare const routeCount: (count: number) => string;
16
20
  export declare function measureCss(routes: readonly RouteStylesheets[], sheets: readonly Stylesheet[], maxGzip: number, assets: string): CssMeasure;
21
+ export declare function rangeMediaQueries(css: string): string[];
22
+ export declare const inlineStyles: (html: string) => string;
23
+ export declare function rangeQueryFailures(sources: readonly CssSource[]): string[];