@elia97/officina 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +88 -12
  2. package/dist/bin.js +5 -1
  3. package/dist/checks/bundle.js +15 -5
  4. package/dist/checks/doctor.js +32 -16
  5. package/dist/checks/lighthouse.js +4 -1
  6. package/dist/checks/links.d.ts +2 -0
  7. package/dist/checks/links.js +69 -0
  8. package/dist/checks/migrate.d.ts +2 -0
  9. package/dist/checks/migrate.js +44 -0
  10. package/dist/checks/placeholders.js +15 -6
  11. package/dist/checks/secrets.d.ts +2 -0
  12. package/dist/checks/secrets.js +46 -0
  13. package/dist/checks/smoke.js +23 -6
  14. package/dist/index.d.ts +1 -0
  15. package/dist/lib/alignment.d.ts +1 -0
  16. package/dist/lib/alignment.js +8 -2
  17. package/dist/lib/bundle-ssr.d.ts +2 -0
  18. package/dist/lib/bundle-ssr.js +1 -1
  19. package/dist/lib/check-placeholders.d.ts +3 -1
  20. package/dist/lib/check-placeholders.js +9 -3
  21. package/dist/lib/check-routes.js +5 -3
  22. package/dist/lib/cli.d.ts +1 -0
  23. package/dist/lib/cli.js +1 -0
  24. package/dist/lib/config-gaps.d.ts +5 -5
  25. package/dist/lib/config-gaps.js +9 -8
  26. package/dist/lib/config.d.ts +31 -3
  27. package/dist/lib/config.js +30 -4
  28. package/dist/lib/database-auth.d.ts +5 -0
  29. package/dist/lib/database-auth.js +81 -0
  30. package/dist/lib/env-schema.d.ts +8 -0
  31. package/dist/lib/env-schema.js +37 -0
  32. package/dist/lib/links.d.ts +30 -0
  33. package/dist/lib/links.js +101 -0
  34. package/dist/lib/migrate.d.ts +11 -0
  35. package/dist/lib/migrate.js +29 -0
  36. package/dist/lib/routes.d.ts +19 -7
  37. package/dist/lib/routes.js +39 -13
  38. package/dist/lib/secrets.d.ts +10 -0
  39. package/dist/lib/secrets.js +53 -0
  40. package/dist/lib/smoke-on-demand.d.ts +8 -0
  41. package/dist/lib/smoke-on-demand.js +65 -0
  42. package/dist/lib/smoke-production.d.ts +4 -1
  43. package/dist/lib/smoke-production.js +2 -0
  44. package/dist/lib/validate.d.ts +2 -0
  45. package/dist/lib/validate.js +9 -1
  46. package/dist/lib/vercel-build.d.ts +1 -0
  47. package/dist/lib/vercel-build.js +26 -4
  48. package/dist/lib/workflows.d.ts +14 -0
  49. package/dist/lib/workflows.js +44 -3
  50. package/dist/smoke.d.ts +1 -0
  51. package/dist/smoke.js +1 -0
  52. package/package.json +1 -1
  53. package/presets/biome.base.json +2 -1
package/README.md CHANGED
@@ -19,6 +19,8 @@ pnpm add -D @elia97/officina
19
19
  "check:roadmap": "officina check roadmap",
20
20
  "check:placeholders": "officina check placeholders",
21
21
  "perf:bundle": "officina check bundle",
22
+ "check:links": "officina check links",
23
+ "check:secrets": "officina check secrets",
22
24
  "smoke:prod": "officina check smoke",
23
25
  "analytics:verify": "officina check analytics",
24
26
  "lhci": "officina check lighthouse",
@@ -38,14 +40,16 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
38
40
 
39
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.
40
42
 
41
- `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.
42
44
 
43
45
  ## Verifiche di build e di produzione
44
46
 
45
47
  | Comando | Cosa guarda | Da dove prende le rotte |
46
48
  |---|---|---|
47
49
  | `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 |
48
- | `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
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 |
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 |
49
53
  | `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
50
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` |
51
55
  | `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
@@ -54,10 +58,18 @@ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`
54
58
 
55
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.
56
60
 
61
+ `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.
62
+
63
+ `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.
64
+
57
65
  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`.
58
66
 
59
67
  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.
60
68
 
69
+ 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 un'anomalia è un avviso, che lo smoke stampa con `·` senza fallire; dalla 0.13.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.
70
+
71
+ 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.
72
+
61
73
  Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
62
74
 
63
75
  ```ts
@@ -74,7 +86,7 @@ export default defineConfig({
74
86
  icons: { background: SITE.themeColor.light },
75
87
  bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
76
88
  smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
77
- features: { analytics: 'required', roadmap: false, botId: 'required' },
89
+ features: { analytics: 'required', roadmap: false, botId: 'required', links: 'required' },
78
90
  routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
79
91
  })
80
92
  ```
@@ -83,7 +95,7 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
83
95
 
84
96
  | Sottopercorso | Cosa esporta |
85
97
  |---|---|
86
- | `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i cinque controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
98
+ | `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i sei controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
87
99
  | `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
88
100
  | `@elia97/officina/icons` | `ICON_SPECS` |
89
101
 
@@ -91,11 +103,15 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
91
103
 
92
104
  `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.
93
105
 
106
+ `routes.disabled` elenca i pattern dinamici che il progetto spegne per scelta, scritti come le chiavi di `routes.representatives`: le news tenute spente finché non ci sono articoli veri, con un `getStaticPaths` che non emette niente. Come per `features`, una pagina si spegne scrivendolo, non dimenticandola: un pattern dinamico che non emette niente senza essere dichiarato resta un fallimento di `check bundle`. Su un pattern spento `check bundle` non pretende pagine e lo elenca come spento, `doctor` non chiede il rappresentante e lo elenca fra le spente, smoke e Lighthouse non lo visitano, anche se il rappresentante c'è ancora, e lo scrivono nell'uscita: quando il progetto riaccende la pagina, qui basta togliere la voce. Il contrario è un fallimento di `check bundle`, perché dichiarazione e build non coincidono: una rotta emessa che soltanto un pattern spento spiega, o una pagina spenta che la build rende a richiesta, come una pagina senza `prerender` con `output: 'server'`. Si spegne solo una pagina prerenderizzata. `doctor` legge il sorgente, e una voce che lì non è una pagina dinamica prerenderizzata di `src/pages` è un suo errore: una pagina statica Astro la genera sempre, e un nome sbagliato non spegne niente. `check bundle` conosce solo le pagine `.astro`: una pagina Markdown di `src/pages`, o un `.html` di `public/`, che cade sotto un pattern spento passa per emessa da quello.
107
+
94
108
  `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.
95
109
 
96
- `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.
110
+ `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.
111
+
112
+ `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.
97
113
 
98
- 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.
114
+ 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.
99
115
 
100
116
  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.
101
117
 
@@ -126,15 +142,20 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
126
142
 
127
143
  | Action | Passi | Cosa resta nel workflow del progetto |
128
144
  |---|---|---|
129
- | `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
145
+ | `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:` |
130
146
  | `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
131
- | `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 |
147
+ | `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:` |
132
148
  | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
133
149
 
134
150
  ```yaml
151
+ concurrency:
152
+ group: deploy-production
153
+ cancel-in-progress: false
154
+
135
155
  jobs:
136
156
  deploy:
137
157
  runs-on: ubuntu-latest
158
+ timeout-minutes: 30
138
159
  environment:
139
160
  name: production
140
161
  url: ${{ steps.deploy.outputs.url }}
@@ -150,17 +171,70 @@ jobs:
150
171
 
151
172
  `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.
152
173
 
153
- 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.
174
+ 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.
154
175
 
155
176
  **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.
156
177
 
178
+ **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.
179
+
180
+ ```yaml
181
+ - name: Branch Neon della corsa
182
+ id: neon
183
+ env:
184
+ NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
185
+ run: |
186
+ [ -n "$NEON_API_KEY" ] || exit 0
187
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
188
+ echo "expires=$(date -u -d '+2 hours' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
189
+ - uses: neondatabase/create-branch-action@v6
190
+ id: branch
191
+ if: steps.neon.outputs.enabled == 'true'
192
+ with:
193
+ project_id: ${{ vars.NEON_PROJECT_ID }}
194
+ api_key: ${{ secrets.NEON_API_KEY }}
195
+ parent_branch: test
196
+ branch_name: ci-${{ github.run_id }}-${{ github.run_attempt }}
197
+ expires_at: ${{ steps.neon.outputs.expires }}
198
+ - uses: Elia97/officina/actions/ci@vX.Y.Z
199
+ with:
200
+ migrate: db:migrate
201
+ database-url: ${{ steps.branch.outputs.db_url }}
202
+ - uses: neondatabase/delete-branch-action@v3
203
+ if: always() && steps.branch.outputs.branch_id != ''
204
+ with:
205
+ project_id: ${{ vars.NEON_PROJECT_ID }}
206
+ branch: ${{ steps.branch.outputs.branch_id }}
207
+ api_key: ${{ secrets.NEON_API_KEY }}
208
+ ```
209
+
210
+ 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`.
211
+
212
+ 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.
213
+
214
+ **Le migrazioni al deploy.** Con un database, anche `actions/deploy` riceve lo script delle migrazioni, con l'indirizzo di produzione:
215
+
216
+ ```yaml
217
+ - uses: Elia97/officina/actions/deploy@vX.Y.Z
218
+ with:
219
+ migrate: db:migrate
220
+ database-url: ${{ secrets.PRODUCTION_DATABASE_URL }}
221
+ ```
222
+
223
+ 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.
224
+
225
+ **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.
226
+
227
+ **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.
228
+
229
+ **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.
230
+
157
231
  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`.
158
232
 
159
233
  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.
160
234
 
161
235
  ## doctor
162
236
 
163
- `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.
237
+ `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.
164
238
 
165
239
  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.
166
240
 
@@ -177,7 +251,9 @@ Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
177
251
  - **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;
178
252
  - **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.
179
253
 
180
- `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.
254
+ 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.
255
+
256
+ `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.
181
257
 
182
258
  **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.
183
259
 
@@ -229,4 +305,4 @@ I test dei generatori girano sui punti di aggancio del progetto di prova, copiat
229
305
 
230
306
  ## Cosa arriverà
231
307
 
232
- I preset di `ecommerce` e `monorepo`.
308
+ I preset di `monorepo`.
package/dist/bin.js CHANGED
@@ -8,9 +8,11 @@ export const CHECKS = {
8
8
  comments: () => import('./checks/comments.js'),
9
9
  language: () => import('./checks/language.js'),
10
10
  lighthouse: () => import('./checks/lighthouse.js'),
11
+ links: () => import('./checks/links.js'),
11
12
  placeholders: () => import('./checks/placeholders.js'),
12
13
  roadmap: () => import('./checks/roadmap.js'),
13
14
  routes: () => import('./checks/routes.js'),
15
+ secrets: () => import('./checks/secrets.js'),
14
16
  smoke: () => import('./checks/smoke.js'),
15
17
  'vercel-cli': () => import('./checks/vercel-cli.js'),
16
18
  };
@@ -19,9 +21,10 @@ const USAGE = `Uso:
19
21
  officina gen [generatore] section, page, component, collection, più quelli del progetto
20
22
  officina gen icons le icone del manifest da public/favicon.svg
21
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
22
25
 
23
26
  Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
24
- bundle legge 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]
25
28
  I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
26
29
  `;
27
30
  // Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
@@ -31,6 +34,7 @@ const GROUPS = {
31
34
  ? { load: () => import('./gen/icons.js'), args: args.slice(1) }
32
35
  : { load: () => import('./gen/run.js'), args },
33
36
  doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
37
+ migrate: (args) => ({ load: () => import('./checks/migrate.js'), args }),
34
38
  };
35
39
  export async function run(argv) {
36
40
  const [group = '', ...rest] = argv;
@@ -9,7 +9,7 @@ import { measureCss, 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';
12
- import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
12
+ import { disabledRouteFailures, expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
13
13
  const DIST = 'dist/client';
14
14
  const ASSETS = join(DIST, '_astro');
15
15
  const PAGES = 'src/pages';
@@ -98,8 +98,15 @@ function printSsrNote(expected, ssr) {
98
98
  for (const file of expected.ssr)
99
99
  console.log(` - ${file}`);
100
100
  }
101
+ function printDisabled({ disabled }) {
102
+ if (disabled.length === 0)
103
+ return;
104
+ console.log('\nSPENTE da `routes.disabled`, nessuna pagina attesa:');
105
+ for (const { label, file } of disabled)
106
+ console.log(` - ${label} (${file})`);
107
+ }
101
108
  export async function main() {
102
- const { bundle = {} } = await loadConfig(process.cwd());
109
+ const { bundle = {}, routes: { disabled } = {} } = await loadConfig(process.cwd());
103
110
  if (missingInput(ASSETS, 'è la build da misurare: prima `pnpm build`'))
104
111
  return 1;
105
112
  if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
@@ -109,14 +116,17 @@ export async function main() {
109
116
  const ssr = readSsr(html.length > 0);
110
117
  const routes = [...html, ...ssr.pages];
111
118
  const pages = measurePages(routes, readChunks(), bundle.budgets ?? []);
112
- const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
113
- const measured = pages.map((page) => page.route);
114
- const failures = ssr.comparesExpectedRoutes ? missingRouteFailures(expected, measured, DIST) : [];
119
+ const expected = expectedRoutes(readPageFiles(PAGES), PAGES, disabled);
120
+ const emitted = { html: html.map((page) => page.route), ssr: ssr.pages.map((page) => page.route) };
121
+ const failures = ssr.comparesExpectedRoutes
122
+ ? [...missingRouteFailures(expected, emitted, DIST), ...disabledRouteFailures(expected, emitted)]
123
+ : [];
115
124
  failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
116
125
  const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
117
126
  printPages(pages, width);
118
127
  failures.push(...checkCss(routes, cssMaxGzip, width));
119
128
  printSsrNote(expected, ssr);
129
+ printDisabled(expected);
120
130
  if (failures.length > 0) {
121
131
  console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
122
132
  return 1;
@@ -3,15 +3,16 @@ 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
- import { expectedRoutes, missingRepresentatives, readPageFiles } from '../lib/routes.js';
15
+ import { expectedRoutes, missingRepresentatives, readPageFiles, unknownDisabled } from '../lib/routes.js';
15
16
  import { toolingGaps } from '../lib/tooling.js';
16
17
  import { packageVersion } from '../lib/versions.js';
17
18
  import { workflowGaps } from '../lib/workflows.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;
@@ -64,11 +57,31 @@ function generatorSections(root, setting) {
64
57
  { title: ANCHORS, off },
65
58
  ];
66
59
  }
67
- function orphanPatterns(root, config) {
60
+ function routeGaps(root, config) {
61
+ if (config === undefined || config instanceof Error)
62
+ return { disabled: [] };
68
63
  const pages = join(root, PAGES);
69
- if (!existsSync(pages) || config === undefined || config instanceof Error)
70
- return [];
71
- return missingRepresentatives(expectedRoutes(readPageFiles(pages), pages), config.routes?.representatives);
64
+ const { representatives, disabled: declared } = config.routes ?? {};
65
+ const expected = expectedRoutes(existsSync(pages) ? readPageFiles(pages) : [], pages, declared);
66
+ return {
67
+ missingRepresentatives: missingRepresentatives(expected, representatives),
68
+ unknownDisabled: unknownDisabled(expected, declared),
69
+ disabled: expected.disabled.map(({ label }) => label),
70
+ };
71
+ }
72
+ function configSection(root, config) {
73
+ const { disabled, ...gaps } = routeGaps(root, config);
74
+ const notes = disabled.length === 0 ? [] : [`spente da \`routes.disabled\`: ${disabled.join(', ')}`];
75
+ return { title: 'officina.config.ts', gaps: configGaps(config, gaps), notes };
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 }];
72
85
  }
73
86
  export async function main() {
74
87
  const root = process.cwd();
@@ -88,7 +101,8 @@ export async function main() {
88
101
  title: 'workflow di GitHub e Dependabot',
89
102
  gaps: [...workflowGaps(files, version), ...dependabotGaps(files)],
90
103
  },
91
- { title: 'officina.config.ts', gaps: configGaps(config, orphanPatterns(root, config)) },
104
+ configSection(root, config),
105
+ ...databaseAuthSections(files, usable(config), manifest),
92
106
  ];
93
107
  console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
94
108
  const findings = [];
@@ -101,6 +115,8 @@ export async function main() {
101
115
  console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
102
116
  const sectionFindings = gaps.map((gap) => ({ ...gap, severity: gap.severity ?? 'error' }));
103
117
  printFindings(sectionFindings, cliOptions([]).format);
118
+ for (const note of section.notes ?? [])
119
+ console.log(` · ${note}`);
104
120
  findings.push(...sectionFindings);
105
121
  }
106
122
  console.log(findings.length === 0
@@ -25,13 +25,16 @@ async function resolveConfig(url) {
25
25
  if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
26
26
  return null;
27
27
  const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
28
- const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), configured.representatives);
28
+ const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, configured.disabled);
29
+ const routes = auditRoutes(expected, configured.representatives);
29
30
  const env = url === undefined ? process.env : { ...process.env, LH_BASE_URL: url };
30
31
  const resolved = lighthouseConfig(rc, routes, env);
31
32
  const where = url === undefined ? '' : ` su ${url}`;
32
33
  console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}${where}\n`);
33
34
  for (const page of resolved.ci.collect.url ?? [])
34
35
  console.log(` ${page}`);
36
+ for (const { label } of expected.disabled)
37
+ console.log(` ${label} — spenta da \`routes.disabled\`, non misurata`);
35
38
  console.log();
36
39
  return resolved;
37
40
  }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(): Promise<number>;
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import process from 'node:process';
5
+ import { readServerFiles } from '../lib/bundle-ssr.js';
6
+ import { missingInput } from '../lib/cli.js';
7
+ import { isRequired, loadConfig } from '../lib/config.js';
8
+ import { brokenLines, htmlLinks, linkReport, servedPaths, siteOrigin } from '../lib/links.js';
9
+ import { expectedRoutes, filesWithExtension, pageRouteOf, readPageFiles, routeOf, routePattern } from '../lib/routes.js';
10
+ import { onDemandPatterns } from '../lib/vercel-build.js';
11
+ const DIST = 'dist/client';
12
+ const PAGES = 'src/pages';
13
+ const SERVER_OUTPUT = 'dist/server';
14
+ const FROM_SOURCE = 'valgono le pagine `.astro` con `prerender = false`';
15
+ const servedPath = (file) => `/${relative(DIST, file).split(sep).join('/')}`;
16
+ function onDemand() {
17
+ const files = readServerFiles();
18
+ const fromBuild = files.length === 0 ? null : onDemandPatterns(files);
19
+ if (fromBuild !== null)
20
+ return { patterns: fromBuild };
21
+ const ssr = existsSync(PAGES) ? expectedRoutes(readPageFiles(PAGES), PAGES).ssr : [];
22
+ const patterns = ssr.map((file) => routePattern(pageRouteOf(file, PAGES)));
23
+ if (files.length > 0)
24
+ return { patterns, note: `il manifest della build Vercel non si riconosce: ${FROM_SOURCE}` };
25
+ if (existsSync(SERVER_OUTPUT))
26
+ return { patterns, note: `build SSR senza l'adapter Vercel: ${FROM_SOURCE}` };
27
+ return { patterns };
28
+ }
29
+ function printReport(report, pages, note) {
30
+ console.log(`\ncheck:links — ${report.internal} link interni su ${pages} pagine di ${DIST}\n`);
31
+ const { external, scheme, anchor } = report.outside;
32
+ console.log(`Restano fuori: ${external} link esterni, ${scheme} con un altro schema (mailto:, tel:, …), ${anchor} ancore, di cui l'id non si verifica.`);
33
+ if (report.onDemand > 0) {
34
+ console.log(`${report.onDemand} link portano a una rotta resa a richiesta: che la pagina esista lo sa solo il server.`);
35
+ }
36
+ if (note !== undefined)
37
+ console.log(`\nNOTA ${note}`);
38
+ }
39
+ export async function main() {
40
+ const config = await loadConfig(process.cwd());
41
+ if (!isRequired(config, 'links')) {
42
+ console.log('\ncheck:links — spento da `features.links: false`\n');
43
+ return 0;
44
+ }
45
+ if (missingInput(DIST, 'è la build di cui si controllano i link: prima `pnpm build`'))
46
+ return 1;
47
+ const files = filesWithExtension(DIST, '').map(servedPath);
48
+ const html = files.filter((file) => file.endsWith('.html'));
49
+ if (html.length === 0) {
50
+ console.error(`\n✗ ${DIST} non contiene nessun .html: senza pagine il controllo dei link non afferma niente.\n`);
51
+ return 1;
52
+ }
53
+ const pages = html.map((file) => ({
54
+ route: routeOf(file, ''),
55
+ hrefs: htmlLinks(readFileSync(join(DIST, file), 'utf8')),
56
+ }));
57
+ const { patterns, note } = onDemand();
58
+ const report = linkReport(pages, siteOrigin(config.siteUrl), servedPaths(files), patterns);
59
+ printReport(report, pages.length, note);
60
+ if (report.broken.length > 0) {
61
+ const lines = brokenLines(report).map((line) => ` - ${line}`);
62
+ console.error(`\n✗ Link interni rotti:\n${lines.join('\n')}\n`);
63
+ return 1;
64
+ }
65
+ console.log('\n✓ Ogni link interno porta a qualcosa che la build serve.\n');
66
+ return 0;
67
+ }
68
+ if (import.meta.main)
69
+ process.exit(await main());
@@ -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;