astroidjs 0.1.1 → 0.2.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 (154) hide show
  1. package/README.md +240 -5
  2. package/bin/astroid.mjs +185 -9
  3. package/dist/analytics/index.d.ts +37 -0
  4. package/dist/analytics/index.js +108 -0
  5. package/dist/astro/csp.d.ts +64 -0
  6. package/dist/astro/csp.js +173 -0
  7. package/dist/astro/index.d.ts +1 -0
  8. package/dist/astro/index.js +7 -0
  9. package/dist/commerce/adapters.d.ts +60 -0
  10. package/dist/commerce/adapters.js +90 -0
  11. package/dist/commerce/checkout-scaffold.d.ts +42 -0
  12. package/dist/commerce/checkout-scaffold.js +306 -0
  13. package/dist/commerce/checkout.d.ts +72 -0
  14. package/dist/commerce/checkout.js +124 -0
  15. package/dist/commerce/index.d.ts +8 -0
  16. package/dist/commerce/index.js +9 -0
  17. package/dist/commerce/loader.d.ts +71 -0
  18. package/dist/commerce/loader.js +90 -0
  19. package/dist/commerce/mirror.d.ts +67 -0
  20. package/dist/commerce/mirror.js +203 -0
  21. package/dist/commerce/roles.d.ts +38 -0
  22. package/dist/commerce/roles.js +93 -0
  23. package/dist/commerce/secrets.d.ts +74 -0
  24. package/dist/commerce/secrets.js +129 -0
  25. package/dist/commerce/sync.d.ts +86 -0
  26. package/dist/commerce/sync.js +154 -0
  27. package/dist/components/sections.d.ts +577 -0
  28. package/dist/components/sections.js +425 -0
  29. package/dist/config.d.ts +174 -12
  30. package/dist/config.js +43 -1
  31. package/dist/email/index.d.ts +4 -0
  32. package/dist/email/index.js +5 -0
  33. package/dist/email/inquiry.d.ts +33 -0
  34. package/dist/email/inquiry.js +63 -0
  35. package/dist/email/send.d.ts +120 -0
  36. package/dist/email/send.js +196 -0
  37. package/dist/email/templates.d.ts +24 -0
  38. package/dist/email/templates.js +184 -0
  39. package/dist/email/theme.d.ts +24 -0
  40. package/dist/email/theme.js +150 -0
  41. package/dist/errors.d.ts +14 -0
  42. package/dist/errors.js +17 -0
  43. package/dist/index.d.ts +14 -0
  44. package/dist/index.js +14 -0
  45. package/dist/map/index.d.ts +3 -0
  46. package/dist/map/index.js +4 -0
  47. package/dist/map/pmtiles.d.ts +92 -0
  48. package/dist/map/pmtiles.js +130 -0
  49. package/dist/map/scaffold.d.ts +29 -0
  50. package/dist/map/scaffold.js +212 -0
  51. package/dist/map/style.d.ts +58 -0
  52. package/dist/map/style.js +154 -0
  53. package/dist/portal/config.d.ts +26 -0
  54. package/dist/portal/config.js +50 -0
  55. package/dist/portal/guard.d.ts +48 -0
  56. package/dist/portal/guard.js +64 -0
  57. package/dist/portal/index.d.ts +5 -0
  58. package/dist/portal/index.js +6 -0
  59. package/dist/portal/nav.d.ts +26 -0
  60. package/dist/portal/nav.js +35 -0
  61. package/dist/portal/scaffold.d.ts +28 -0
  62. package/dist/portal/scaffold.js +140 -0
  63. package/dist/portal/session.d.ts +36 -0
  64. package/dist/portal/session.js +86 -0
  65. package/dist/portfolio/index.d.ts +1 -0
  66. package/dist/portfolio/index.js +4 -0
  67. package/dist/portfolio/scaffold.d.ts +9 -0
  68. package/dist/portfolio/scaffold.js +93 -0
  69. package/dist/project/actions.d.ts +3 -0
  70. package/dist/project/actions.js +106 -0
  71. package/dist/project/generate.d.ts +15 -0
  72. package/dist/project/generate.js +144 -2
  73. package/dist/project/index.d.ts +2 -0
  74. package/dist/project/index.js +2 -0
  75. package/dist/project/scaffold.d.ts +29 -0
  76. package/dist/project/scaffold.js +140 -0
  77. package/dist/pwa/generate.d.ts +49 -0
  78. package/dist/pwa/generate.js +218 -0
  79. package/dist/pwa/index.d.ts +1 -0
  80. package/dist/pwa/index.js +2 -0
  81. package/dist/queues/consumer.d.ts +29 -0
  82. package/dist/queues/consumer.js +37 -0
  83. package/dist/queues/index.d.ts +4 -0
  84. package/dist/queues/index.js +5 -0
  85. package/dist/queues/messages.d.ts +60 -0
  86. package/dist/queues/messages.js +71 -0
  87. package/dist/queues/scaffold.d.ts +44 -0
  88. package/dist/queues/scaffold.js +204 -0
  89. package/dist/queues/webhook.d.ts +60 -0
  90. package/dist/queues/webhook.js +81 -0
  91. package/dist/realtime/index.d.ts +1 -0
  92. package/dist/realtime/index.js +4 -0
  93. package/dist/realtime/scaffold.d.ts +30 -0
  94. package/dist/realtime/scaffold.js +159 -0
  95. package/dist/schema/collections.d.ts +42 -8
  96. package/dist/schema/collections.js +102 -8
  97. package/dist/schema/generate.js +10 -1
  98. package/dist/secrets.d.ts +54 -0
  99. package/dist/secrets.js +80 -0
  100. package/dist/security/index.d.ts +1 -0
  101. package/dist/security/index.js +2 -0
  102. package/dist/security/rate-rules.d.ts +21 -0
  103. package/dist/security/rate-rules.js +107 -0
  104. package/dist/seo/index.d.ts +3 -0
  105. package/dist/seo/index.js +4 -0
  106. package/dist/seo/resolve.d.ts +68 -0
  107. package/dist/seo/resolve.js +73 -0
  108. package/dist/seo/routes.d.ts +44 -0
  109. package/dist/seo/routes.js +104 -0
  110. package/dist/seo/structured-data.d.ts +51 -0
  111. package/dist/seo/structured-data.js +105 -0
  112. package/dist/status.d.ts +51 -0
  113. package/dist/status.js +113 -0
  114. package/dist/worker/generate.d.ts +18 -10
  115. package/dist/worker/generate.js +325 -37
  116. package/dist/worker/routes.d.ts +1 -1
  117. package/dist/worker/routes.js +42 -0
  118. package/dist/workflow/advance.d.ts +102 -0
  119. package/dist/workflow/advance.js +145 -0
  120. package/dist/workflow/config.d.ts +60 -0
  121. package/dist/workflow/config.js +73 -0
  122. package/dist/workflow/generate.d.ts +22 -0
  123. package/dist/workflow/generate.js +138 -0
  124. package/dist/workflow/index.d.ts +3 -0
  125. package/dist/workflow/index.js +4 -0
  126. package/package.json +21 -5
  127. package/src/components/Editable.astro +33 -9
  128. package/src/components/JustifiedGallery.astro +254 -0
  129. package/src/components/MediaSlot.astro +178 -0
  130. package/src/components/PortalShell.astro +80 -0
  131. package/src/components/RegisterSW.astro +45 -0
  132. package/src/components/Section.astro +101 -35
  133. package/src/components/Sections.astro +64 -0
  134. package/src/components/Seo.astro +57 -0
  135. package/src/components/StageBar.astro +137 -0
  136. package/src/components/StructuredData.astro +33 -0
  137. package/src/components/justify.ts +170 -0
  138. package/src/components/media-meta.ts +174 -0
  139. package/src/components/sections/AboutIntro.astro +46 -0
  140. package/src/components/sections/Banner.astro +31 -0
  141. package/src/components/sections/Contact.astro +22 -9
  142. package/src/components/sections/Cta.astro +33 -10
  143. package/src/components/sections/Faq.astro +50 -0
  144. package/src/components/sections/FeatureGrid.astro +40 -11
  145. package/src/components/sections/Gallery.astro +46 -0
  146. package/src/components/sections/Hero.astro +40 -12
  147. package/src/components/sections/LocationHours.astro +59 -0
  148. package/src/components/sections/Media.astro +44 -0
  149. package/src/components/sections/PricingTiers.astro +79 -0
  150. package/src/components/sections/ProductGrid.astro +73 -0
  151. package/src/components/sections/SplitImage.astro +61 -0
  152. package/src/components/sections/Steps.astro +58 -0
  153. package/src/components/sections/Testimonial.astro +51 -0
  154. package/src/components/sections.ts +452 -67
package/README.md CHANGED
@@ -46,7 +46,7 @@ export default defineAstroid({
46
46
  key: "coracle",
47
47
  archetype: "storefront",
48
48
  theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
49
- sections: ["hero", "marquee", "featured", "productGrid", "visit"],
49
+ sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
50
50
  commerce: { provider: "square" },
51
51
  deploy: { platform: "cloudflare" },
52
52
  });
@@ -59,12 +59,247 @@ export default defineAstroid({
59
59
  key: "megbowen",
60
60
  archetype: "portfolio",
61
61
  theme: { name: "Meg Bowen Studio", colors: { brand: "#2b2b2b" } },
62
- sections: ["hero", "gallery", "story", "contact"],
63
- portal: { enabled: true, gated: true },
62
+ sections: ["hero", "gallery", "aboutIntro", "contact"],
63
+ portal: { enabled: true },
64
64
  deploy: { platform: "cloudflare" },
65
65
  });
66
66
  ```
67
67
 
68
+ ## Commerce
69
+
70
+ **Providers fill roles.** Not "the commerce provider" — the toolkit's clients
71
+ make that impossible: `commerce/stripe` has no catalog API, `commerce/fourthwall`
72
+ has no invoicing, Square does both. So `storefront` and `invoicing` are assigned
73
+ independently, and a provider put in a role it can't serve fails at config load
74
+ rather than at runtime on the first invoice.
75
+
76
+ ```ts
77
+ commerce: { storefront: "fourthwall", invoicing: "stripe" } // two providers
78
+ commerce: { provider: "square" } // shorthand → storefront
79
+ ```
80
+
81
+ **The catalog is mirrored with a pulled/owned split.** The provider is the source
82
+ of truth; D1 holds the owner's edits. `mode: "mirror"` keeps the catalog fields
83
+ in D1 (fast reads, briefly stale); `mode: "overlay"` keeps only the owner's
84
+ columns (never stale, one provider round-trip per read). The sync **never writes
85
+ an owned column** — a sync that does silently reverts the owner's work, and
86
+ they find out days later. `slug` is owned for that reason: it's the public URL,
87
+ so a provider rename must not break links.
88
+
89
+ ```ts
90
+ commerce: {
91
+ provider: "square",
92
+ catalog: { owned: { tone: { type: "text", values: ["cream", "teal"] } } },
93
+ }
94
+ ```
95
+
96
+ Everything else follows from that table. `astroidCatalogLoaderConfig` reads it
97
+ for the Live Content Collection, and the adapters normalize before the row is
98
+ written — so one loader definition serves a Square site and a Fourthwall site,
99
+ which is the drift the module exists to kill.
100
+
101
+ **Checkout is server-authoritative.** `verifyCheckout` treats the client's price
102
+ as a staleness check, never an input to the charge: re-price server-side, refuse
103
+ on mismatch. `checkoutIdempotencyKey` derives a stable key from the verified cart
104
+ **and a required `identity`**, so a double-clicked Pay button charges once —
105
+ while two customers buying the same thing stay two charges.
106
+
107
+ ```ts
108
+ const key = await checkoutIdempotencyKey(check, "order", cartId);
109
+ ```
110
+
111
+ Pass something stable across a retry of this attempt and distinct between buyers
112
+ (a cart id, checkout-session id, or portal user id). It is required, and empty is
113
+ refused, because a key derived from cart contents alone collides between
114
+ customers: providers scope idempotency keys per account for ~24h, so the second
115
+ buyer's charge is deduped into the first buyer's order and never happens.
116
+
117
+ ## The webhook pipeline
118
+
119
+ Configure `commerce` and the generated worker stops being fetch-only: it composes
120
+ Astro's SSR handler with a **queue consumer** and a **cron**, and the scaffold
121
+ gains a webhook receiver plus a consumer seam. `--commerce <provider>` on
122
+ `pnpm create astroid` sets it all up.
123
+
124
+ The receiver's ordering is the part worth knowing. `handleWebhook` verifies the
125
+ HMAC over the **raw body before anything parses it** — parse first and an
126
+ unauthenticated caller reaches the JSON parser and everything downstream, and
127
+ re-serializing a parsed body to check a signature is how signature checks quietly
128
+ stop checking anything. It then enqueues and returns, so the response doesn't
129
+ wait on the work.
130
+
131
+ Status codes are the only backpressure signal a provider gives you, so each one
132
+ is picked for what it tells the sender to do:
133
+
134
+ | Situation | Code | Why |
135
+ |---|---|---|
136
+ | Secret unprovisioned | 503 | Dormant is temporary — keep retrying so events delivered before you set the secret still land |
137
+ | Bad / missing signature | 401 | Terminal. It won't verify on retry either, and retrying turns a misconfiguration into a flood |
138
+ | Body isn't JSON | 400 | Terminal for the same reason |
139
+ | Enqueue failed | 503 | The signature checked out, so the event is real — ask for redelivery |
140
+ | Enqueued | 202 | Accepted, not done. That's the point of a queue |
141
+
142
+ On the consumer side `astroidQueueHandler` owns the dispatch every site wrote: a
143
+ periodic refresh re-syncs, a webhook re-syncs *only* if it touched the catalog,
144
+ and everything else acks as a no-op. That last part matters — order and payment
145
+ events arrive in volume and have nothing local to update, so treating them as
146
+ actionable turns a busy sales day into a refresh storm.
147
+
148
+ The cron **enqueues** rather than running inline, so the safety-net re-sync takes
149
+ the same retry and DLQ path as everything else. Retries and DLQ routing live in
150
+ `wrangler.jsonc`, because they're Cloudflare's job, not the consumer's.
151
+
152
+ ## Transactional email
153
+
154
+ Four templates — sign-in link, password reset, and the inquiry pair (notify the
155
+ owner, confirm to the sender) — over the toolkit's email shell. Each renders HTML
156
+ **and** plaintext from one definition: a message with no text/plain part scores
157
+ worse with spam filters, and for a sign-in link the plaintext body is what a
158
+ terminal client shows and what the dev log prints.
159
+
160
+ `astroidMailTheme(config)` derives the whole mail theme from `theme.colors`.
161
+ Neutrals stay fixed (they're typography choices, not brand ones); what varies is
162
+ the accent and the five-cell masthead band, built as a ramp so it reads as
163
+ designed whether you configured one brand colour or three. The accent is
164
+ **contrast-corrected** — a brand yellow used verbatim as 11px uppercase text on a
165
+ near-white card is unreadable, and mail clients have no dark-mode escape hatch.
166
+ Pass overrides for any slot you want to own.
167
+
168
+ Delivery is best-effort and never throws. Mail here is always the notification of
169
+ something already durable — the inquiry row is inserted, the account exists — so
170
+ a failure must not fail the request that caused it, and messages send
171
+ independently so the owner's copy still arrives when a visitor typos their
172
+ address. With no `EMAIL` binding the mailer is **dormant**: it logs the rendered
173
+ message instead of dropping it, which is what makes "click the magic link" work
174
+ under `wrangler dev`.
175
+
176
+ ```ts
177
+ formRoute({
178
+ form: contactForm,
179
+ // Fires after the insert, off the response path — store-and-forward.
180
+ onSubmit: (values, env) => sendInquiryMail(astroidConfig, env, values),
181
+ });
182
+ ```
183
+
184
+ ## SEO
185
+
186
+ A settings-driven head, structured data, and the two crawler files — first-party,
187
+ no `astro-seo` dependency.
188
+
189
+ `<Seo>` resolves three levels (page override → the page's own default →
190
+ `site_settings`) with one rule worth knowing: an **empty string is unset**, so
191
+ clearing a field in the editor falls back instead of publishing a blank `<meta>`.
192
+ The title template applies only when a page supplies its own title, so the home
193
+ page reads `Acme Coffee`, not `Acme Coffee | Acme Coffee`. `disableIndexing` in
194
+ settings is a site-wide kill switch that beats any page asking to be indexed —
195
+ useful for staging.
196
+
197
+ `<StructuredData>` emits a schema.org `@graph`: the business, the `WebSite`, and
198
+ optionally the entity the page is *about* (a Product, a VisualArtwork). The
199
+ business `@type` comes from the archetype (`storefront` → `Store`, `portfolio` →
200
+ `Person`); set `seo.businessType` to a narrower subtype whenever you know one.
201
+ The payload is escaped with `escapeJsonLd`, not `JSON.stringify` — `stringify`
202
+ doesn't escape `<`, so an editor-authored value containing `</script>` would
203
+ close the tag early and inject markup into `<head>`.
204
+
205
+ `robots.txt` and `sitemap.xml` derive their disallow list from the same config
206
+ (`astroidNoindexPaths`), so the two files can't disagree about what's crawlable.
207
+ Both are **origin-aware** — built from the serving origin rather than a
208
+ configured domain, because a preview deploy advertising the production host
209
+ invites its content to be indexed under the real domain.
210
+
211
+ ## Security defaults
212
+
213
+ Two stack-wide concerns every site was re-deriving by hand, moved into the
214
+ framework.
215
+
216
+ **Rate-limit rules are data, derived from the config.** The generated middleware
217
+ calls `astroidRateRules(config)`: the editor magic-link always (the
218
+ email-bombing target, so the tightest budget in the set), the portal's
219
+ credential surfaces when `portal.enabled`, checkout when `commerce` is set. The
220
+ session-gated editor API stays out on purpose — a limiter that can lock the
221
+ owner out of their own studio is worse than the abuse it stops. Add or override
222
+ via `security.rateRules`, which is matched *before* the defaults, so you replace
223
+ one budget rather than the whole set.
224
+
225
+ **The CSP is composed, and it's split for a reason.** `astroidSecurity(config)`
226
+ gives `astro.config.mjs` its `security` block. Astro owns `script-src` — it
227
+ hashes every script it processes, so the policy needs no `'unsafe-inline'` — and
228
+ Astroid adds the one hash Astro can't produce itself: Solid's hydration
229
+ bootstrap, injected by `@astrojs/solid-js` on every page with an island.
230
+ Computing it from `generateHydrationScript()` means it tracks solid-js upgrades
231
+ instead of going stale as a copy-pasted literal. Meanwhile the generated
232
+ middleware rewrites *only* `style-src`, because Louise's data-driven `style=""`
233
+ carriers need `'unsafe-inline'` and a single hash in that directive would void
234
+ it per spec — the two cannot share one directive.
235
+
236
+ ```js
237
+ // astro.config.mjs
238
+ import { ASTROID_VITE_BUILD, astroidSecurity } from "astroidjs/astro";
239
+ import astroidConfig from "./astroid.config.ts";
240
+
241
+ export default defineConfig({
242
+ security: astroidSecurity(astroidConfig),
243
+ vite: { build: { ...ASTROID_VITE_BUILD } }, // assetsInlineLimit: 0 — an
244
+ }); // inlined asset can't be hashed
245
+ ```
246
+
247
+ Enabled modules contribute their own origins (a commerce provider's SDK hosts,
248
+ the captcha frame); `security.cspOrigins` adds anything Astroid can't see.
249
+
250
+ ## Modules are dormant, not broken
251
+
252
+ Astroid's optional modules are opt-in at the *config* level, never at the
253
+ *account* level: switching commerce on must not require a Square account before
254
+ `pnpm dev` will boot. So a module whose secrets aren't provisioned is **dormant**
255
+ — it renders, it serves, it says out loud that it's simulated, and it never calls
256
+ upstream with a dummy credential. A fresh clone runs with zero external accounts.
257
+
258
+ `create-astroid` seeds every module secret with one loud sentinel,
259
+ `DUMMY_REPLACE_ME`, so a scaffold has a complete and valid binding set and no
260
+ real credentials. Reading a secret back that still holds the sentinel — or that
261
+ is absent, empty, or bound to an unprovisioned store — yields `null`:
262
+
263
+ ```ts
264
+ import { resolveModuleSecrets, describeModuleStatus } from "astroidjs";
265
+
266
+ const secrets = await resolveModuleSecrets({
267
+ SQUARE_ACCESS_TOKEN: env.SQUARE_ACCESS_TOKEN,
268
+ SQUARE_WEBHOOK_SECRET: env.SQUARE_WEBHOOK_SECRET,
269
+ });
270
+
271
+ if (!secrets.configured) {
272
+ console.warn(describeModuleStatus("commerce", secrets));
273
+ // → commerce: dormant (simulated) — unprovisioned secret(s): SQUARE_WEBHOOK_SECRET
274
+ return simulatedCheckout();
275
+ }
276
+ ```
277
+
278
+ Partial provisioning counts as dormant. A half-configured integration fails
279
+ mid-checkout rather than at boot, which is precisely the failure this convention
280
+ exists to prevent.
281
+
282
+ The scaffold ships one worked example: Turnstile captcha on the **editor
283
+ sign-in**, seeded with the sentinel secret plus Cloudflare's always-passing test
284
+ site key, enforcing only once **both** halves are real — so provisioning one of
285
+ them can't lock you out of your own sign-in.
286
+
287
+ Both halves matter, and the second one is the reason this is worth spelling out.
288
+ `getLouiseAuth` registers Better Auth's captcha plugin on `/sign-in/magic-link`
289
+ as soon as the pair is real, and that plugin rejects any request without an
290
+ `x-captcha-response` header. So the login page renders the widget under exactly
291
+ the same condition the server arms the check — `turnstileSiteKey` returns null
292
+ for the test key, the same test `activeCaptchaSecret` applies — and forwards the
293
+ token in that header. A gate that turns on server-side while the page keeps
294
+ posting without a token is not a half-configured integration; it is a locked
295
+ door with the owner outside.
296
+
297
+ The **public contact form** is a separate surface and is not captcha-gated: its
298
+ spam defence is the honeypot, the minimum time-to-submit, and the rate limit.
299
+ `FormSpamConfig.turnstile` exists in the toolkit if you want to add it, but
300
+ `formRoute` is generated without `turnstileSecret`, so switching the flag on
301
+ alone would not enforce anything.
302
+
68
303
  ## CLI
69
304
 
70
305
  The `astroid` command turns the config into the Louise wiring and keeps it in
@@ -87,7 +322,7 @@ The generated trio carries a "do not hand-edit" banner — `generate` (and
87
322
  config to catch drift. Your `wrangler.jsonc` is scaffolded once and then yours to
88
323
  edit (real binding ids, secrets); `generate` never touches it.
89
324
 
90
- New projects come from the `create-astroid` scaffold (`npm create astroid`), which
325
+ New projects come from the `create-astroid` scaffold (`pnpm create astroid`), which
91
326
  writes the floor — config, the generated trio, `wrangler.jsonc`, and the baseline
92
327
  Astro app — in one step.
93
328
 
@@ -98,7 +333,7 @@ Astro app — in one step.
98
333
  3. ✅ Config → generated `worker.ts` + middleware (no hand-wired route ordering).
99
334
  4. ✅ `<Section>` / `<Editable>` / `<Collection>` component primitives.
100
335
  5. ✅ **CLI** — `astroid generate / doctor / dev / build / deploy`; `create-astroid`
101
- scaffold (`npm create astroid`).
336
+ scaffold (`pnpm create astroid`).
102
337
 
103
338
  ## License
104
339
 
package/bin/astroid.mjs CHANGED
@@ -77,7 +77,7 @@ async function loadConfig(cwd, explicit) {
77
77
 
78
78
  // --- commands --------------------------------------------------------------
79
79
  async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
80
- const { generateAstroidProject } = await import(GENERATORS_URL);
80
+ const { generateAstroidProject, generateAstroidScaffoldFiles } = await import(GENERATORS_URL);
81
81
  const { config } = await loadConfig(cwd, flags.config);
82
82
  const files = generateAstroidProject(config);
83
83
  for (const file of files) {
@@ -86,12 +86,46 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
86
86
  writeFileSync(abs, file.contents);
87
87
  if (!quiet) out(` ✓ ${file.path}`);
88
88
  }
89
- if (!quiet) out(`Generated ${files.length} file(s) from your defineAstroid config.`);
89
+
90
+ // Scaffold-once files for whatever modules the config switched on.
91
+ //
92
+ // Written only when ABSENT — each is a seam the project owns, so overwriting
93
+ // one would destroy the edit it exists to hold. But a MISSING one is not a
94
+ // choice the user made: the trio above emits static imports of `./queue.js`
95
+ // and `./portal-auth.js`, so a config that gained a module after scaffold
96
+ // regenerated a project that couldn't resolve its own imports. Completing the
97
+ // config change is what makes "one typed config" true.
98
+ const created = [];
99
+ for (const file of generateAstroidScaffoldFiles(config)) {
100
+ const abs = join(cwd, file.path);
101
+ const exists = existsSync(abs);
102
+ if (file.apply === "append-once") {
103
+ const current = exists ? readFileSync(abs, "utf8") : "";
104
+ if (file.marker && current.includes(file.marker)) continue;
105
+ mkdirSync(dirname(abs), { recursive: true });
106
+ writeFileSync(abs, current + file.contents);
107
+ created.push(file.path);
108
+ continue;
109
+ }
110
+ if (exists) continue;
111
+ mkdirSync(dirname(abs), { recursive: true });
112
+ writeFileSync(abs, file.contents);
113
+ created.push(file.path);
114
+ }
115
+ for (const path of created) if (!quiet) out(` + ${path} (scaffolded — yours to edit)`);
116
+
117
+ if (!quiet) {
118
+ out(`Generated ${files.length} file(s) from your defineAstroid config.`);
119
+ if (created.length > 0) {
120
+ out(`Scaffolded ${created.length} new module file(s); existing ones were left alone.`);
121
+ }
122
+ }
90
123
  return files;
91
124
  }
92
125
 
93
126
  async function cmdDoctor(cwd, flags) {
94
- const { generateAstroidProject } = await import(GENERATORS_URL);
127
+ const { generateAstroidProject, generateAstroidScaffoldFiles, astroidUsesQueues } =
128
+ await import(GENERATORS_URL);
95
129
  const { config, path: configPath } = await loadConfig(cwd, flags.config);
96
130
 
97
131
  const problems = []; // { level: "error" | "warn", msg }
@@ -99,6 +133,8 @@ async function cmdDoctor(cwd, flags) {
99
133
  const warn = (msg) => problems.push({ level: "warn", msg });
100
134
  const oks = [];
101
135
  const ok = (msg) => oks.push(msg);
136
+ // Neither pass nor problem: informational lines about what's switched on.
137
+ const notes = [];
102
138
 
103
139
  ok(`config loads and validates (${rel(cwd, configPath)})`);
104
140
 
@@ -108,12 +144,28 @@ async function cmdDoctor(cwd, flags) {
108
144
  if (!existsSync(abs)) {
109
145
  err(`${file.path} is missing — run \`astroid generate\`.`);
110
146
  } else if (readFileSync(abs, "utf8") !== file.contents) {
111
- warn(`${file.path} is stale (out of sync with your config) — run \`astroid generate\`.`);
147
+ // An ERROR, not a warning. These three carry a "do not hand-edit" banner
148
+ // and are a pure function of the config, so "stale" means the project on
149
+ // disk is not the project the config describes — which is the single
150
+ // condition doctor exists to catch. As a warning it printed "healthy" and
151
+ // exited 0, so `pnpm doctor` could not gate CI on it.
152
+ err(`${file.path} is stale (out of sync with your config) — run \`astroid generate\`.`);
112
153
  } else {
113
154
  ok(`${file.path} is up to date`);
114
155
  }
115
156
  }
116
157
 
158
+ // 1b. Scaffold-once module files. A missing one is an ERROR: the trio above
159
+ // emits static imports of `./queue.js` / `./portal-auth.js`, so a config
160
+ // that names a module whose seam was never written produces a project that
161
+ // cannot resolve its own imports. This is precisely the state that used to
162
+ // report "healthy, 2 warning(s)" and exit 0.
163
+ for (const file of generateAstroidScaffoldFiles(config)) {
164
+ if (file.apply === "append-once") continue; // accumulated, not owned — see below
165
+ if (existsSync(join(cwd, file.path))) ok(`${file.path} present`);
166
+ else err(`${file.path} is missing (required by your config) — run \`astroid generate\`.`);
167
+ }
168
+
117
169
  // 2. wrangler.jsonc bindings — presence checks + placeholder detection. Read as
118
170
  // text (JSONC with comments/trailing commas) rather than parse, to stay robust.
119
171
  const wranglerPath = join(cwd, "wrangler.jsonc");
@@ -122,6 +174,50 @@ async function cmdDoctor(cwd, flags) {
122
174
  } else {
123
175
  const w = readFileSync(wranglerPath, "utf8");
124
176
  const hasBinding = (name) => new RegExp(`"binding"\\s*:\\s*"${name}"`).test(w);
177
+
178
+ // wrangler.jsonc is scaffold-once (so a provisioned binding id is never
179
+ // clobbered), which means a config change can never update it. Nothing
180
+ // detected the resulting drift: switching on commerce made the generated
181
+ // worker call `env.COMMERCE_QUEUE.send()` against a wrangler.jsonc with no
182
+ // queues block at all, and both doctor and deploy reported success.
183
+ //
184
+ // So check every binding the GENERATED code actually dereferences, not just
185
+ // the three the baseline happens to have.
186
+ const requiredBindings = [
187
+ { name: "RL", what: "KV namespace", why: "the rate limiter in src/middleware.ts" },
188
+ { name: "DRAFTS", what: "KV namespace", why: "the autosave draft buffer" },
189
+ ...(astroidUsesQueues(config)
190
+ ? [
191
+ {
192
+ name: "COMMERCE_QUEUE",
193
+ what: "Queue",
194
+ why: "the webhook producer in src/worker.ts",
195
+ },
196
+ ]
197
+ : []),
198
+ ];
199
+ for (const b of requiredBindings) {
200
+ if (hasBinding(b.name)) ok(`wrangler: ${b.what} \`${b.name}\` binding present`);
201
+ else
202
+ err(
203
+ `wrangler.jsonc has no ${b.what} \`${b.name}\` binding, but ${b.why} uses it. ` +
204
+ "Your config gained a module after this file was scaffolded — add the binding by hand " +
205
+ "(wrangler.jsonc is never regenerated, so provisioned ids are never clobbered).",
206
+ );
207
+ }
208
+
209
+ // Email Sending declares its binding as `"name"`, not `"binding"`, so the
210
+ // regex above cannot see it. Checked separately because sign-in depends on
211
+ // it: the magic link is console-logged in dev and EMAILED in production, so
212
+ // a missing binding is a site nobody can sign in to — and it fails only once
213
+ // deployed, which is the one place nothing in this repo exercises.
214
+ if (/"send_email"\s*:/.test(w)) ok("wrangler: Email Sending `EMAIL` binding present");
215
+ else
216
+ err(
217
+ "wrangler.jsonc has no `send_email` binding, but production sign-in emails the " +
218
+ "magic link (it is only console-logged in dev). Add: \"send_email\": [{ \"name\": \"EMAIL\" }]",
219
+ );
220
+
125
221
  if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
126
222
  else err("wrangler.jsonc has no D1 `DB` binding.");
127
223
  if (hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
@@ -141,8 +237,45 @@ async function cmdDoctor(cwd, flags) {
141
237
  if (existsSync(join(cwd, "migrations"))) ok("migrations/ directory present");
142
238
  else warn("no migrations/ directory — create your D1 schema migrations there.");
143
239
 
240
+ // 4. Local secret provisioning — which modules will run dormant under
241
+ // `astroid dev`, and what to set to wake them.
242
+ //
243
+ // Scoped deliberately to SECRETS, read from .dev.vars. Doctor is a static
244
+ // CLI: it cannot see runtime bindings (EMAIL, the queue), so claiming
245
+ // "email is dormant" here would be reporting its own blindness as the
246
+ // project's state. What it CAN say for certain is whether a value is still
247
+ // the placeholder — and that is the half developers actually forget.
248
+ //
249
+ // A dormant module is never an error. It is the documented default, and a
250
+ // fresh scaffold is expected to report every module dormant.
251
+ const { astroidSecretNames, ASTROID_SECRET_PLACEHOLDER } = await import(GENERATORS_URL);
252
+ const devVarsPath = join(cwd, ".dev.vars");
253
+ const devVars = existsSync(devVarsPath) ? parseDotEnv(readFileSync(devVarsPath, "utf8")) : null;
254
+
255
+ if (!devVars) {
256
+ warn(
257
+ "no .dev.vars — every module runs dormant (simulated) locally. " +
258
+ "Copy .env.example to .dev.vars to change that.",
259
+ );
260
+ }
261
+ for (const [moduleName, names] of Object.entries(astroidSecretNames(config))) {
262
+ if (names.length === 0) continue;
263
+ const unset = names.filter((name) => {
264
+ const value = (devVars?.[name] ?? "").trim();
265
+ return value === "" || value === ASTROID_SECRET_PLACEHOLDER;
266
+ });
267
+ // `core` is not a module — its secrets each gate their own thing (sessions
268
+ // fail closed off localhost, Turnstile needs a matched pair), so a blanket
269
+ // "dormant" line would be wrong. Report it as provisioning, not dormancy.
270
+ if (unset.length === 0) ok(`${moduleName}: all secrets provisioned in .dev.vars`);
271
+ else if (moduleName === "core")
272
+ notes.push(`core: ${unset.length} secret(s) unset locally (${unset.join(", ")})`);
273
+ else notes.push(`${moduleName}: dormant (simulated) — unprovisioned: ${unset.join(", ")}`);
274
+ }
275
+
144
276
  // --- report ---
145
277
  for (const m of oks) out(` ✓ ${m}`);
278
+ for (const n of notes) out(` · ${n}`);
146
279
  for (const p of problems) {
147
280
  if (p.level === "warn") out(` ! ${p.msg}`);
148
281
  else out(` ✗ ${p.msg}`);
@@ -206,6 +339,15 @@ function readWranglerFacts(text) {
206
339
  binding: m[1],
207
340
  id: m[2],
208
341
  })),
342
+ // Queue names, from the producer + the consumer's dead_letter_queue. Unlike
343
+ // D1/R2/KV these are referenced BY NAME, so there's no id to patch back —
344
+ // creating them is the whole job.
345
+ queues: [
346
+ ...new Set([
347
+ ...[...text.matchAll(/"queue":\s*"([^"]+)"/g)].map((m) => m[1]),
348
+ ...[...text.matchAll(/"dead_letter_queue":\s*"([^"]+)"/g)].map((m) => m[1]),
349
+ ]),
350
+ ],
209
351
  // Real (uncommented) account_id line, filled in?
210
352
  hasAccount: /^\s*"account_id":\s*"[^<][^"]*"/m.test(text),
211
353
  };
@@ -225,6 +367,11 @@ function provisionPlan(facts) {
225
367
  steps.push({ kind: "kv", name: binding, args: ["kv", "namespace", "create", binding] });
226
368
  }
227
369
  }
370
+ // Queues carry no id, so there's no placeholder to test — creating one that
371
+ // already exists just errors, which the runner tolerates (same as R2).
372
+ for (const queue of facts.queues) {
373
+ steps.push({ kind: "queue", name: queue, args: ["queues", "create", queue] });
374
+ }
228
375
  return steps;
229
376
  }
230
377
 
@@ -255,8 +402,12 @@ async function cmdDeploy(cwd, flags, rest) {
255
402
  const wranglerPath = join(cwd, "wrangler.jsonc");
256
403
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
257
404
 
258
- // Regenerate so the shipped worker/schema always match the config.
259
- await cmdGenerate(cwd, flags, { quiet: true });
405
+ // Regenerate so the shipped worker/schema always match the config — but NOT
406
+ // on a dry run. This used to sit above the `--dry-run` guard, so a command
407
+ // that ends by printing "(dry run — nothing executed)" had already rewritten
408
+ // three files and silently discarded any local edits to them. Probing the
409
+ // plan on a working branch is exactly when someone has local edits.
410
+ if (!dryRun) await cmdGenerate(cwd, flags, { quiet: true });
260
411
 
261
412
  let wrangler = readFileSync(wranglerPath, "utf8");
262
413
  const facts = readWranglerFacts(wrangler);
@@ -301,8 +452,11 @@ async function cmdDeploy(cwd, flags, rest) {
301
452
  for (const s of plan) {
302
453
  out(`\n▸ wrangler ${s.args.join(" ")}`);
303
454
  const res = runInherit(s.args);
304
- // R2 bucket create is idempotent-ish (an existing bucket errors) — tolerate it.
305
- if (res.status !== 0 && s.kind !== "r2") fail(`Provisioning failed at: wrangler ${s.args.join(" ")}`);
455
+ // R2 bucket + queue creates are idempotent-ish (an existing one errors) —
456
+ // tolerate those so a re-run of `astroid deploy` isn't a hard stop.
457
+ if (res.status !== 0 && s.kind !== "r2" && s.kind !== "queue") {
458
+ fail(`Provisioning failed at: wrangler ${s.args.join(" ")}`);
459
+ }
306
460
 
307
461
  if (s.kind === "d1") {
308
462
  const id = lookupId(wranglerBin, cwd, ["d1", "list", "--json"], (rows) => rows.find((r) => r.name === s.name)?.uuid);
@@ -342,6 +496,28 @@ async function cmdDeploy(cwd, flags, rest) {
342
496
  }
343
497
 
344
498
  // --- helpers ---------------------------------------------------------------
499
+ /**
500
+ * Minimal KEY=VALUE reader for .dev.vars. Not a dotenv implementation — doctor
501
+ * only needs to know whether a value is absent, empty, or the placeholder, so
502
+ * expansion, multiline values, and `export` prefixes are out of scope. Quotes
503
+ * are stripped because wrangler accepts them and a quoted placeholder must
504
+ * still read as a placeholder.
505
+ */
506
+ function parseDotEnv(text) {
507
+ const vars = {};
508
+ for (const line of text.split("\n")) {
509
+ const trimmed = line.trim();
510
+ if (!trimmed || trimmed.startsWith("#")) continue;
511
+ const eq = trimmed.indexOf("=");
512
+ if (eq < 1) continue;
513
+ const key = trimmed.slice(0, eq).trim();
514
+ let value = trimmed.slice(eq + 1).trim();
515
+ if (value.length >= 2 && /^(".*"|'.*')$/s.test(value)) value = value.slice(1, -1);
516
+ vars[key] = value;
517
+ }
518
+ return vars;
519
+ }
520
+
345
521
  function out(s) {
346
522
  process.stdout.write(`${s}\n`);
347
523
  }
@@ -362,7 +538,7 @@ Usage:
362
538
  astroid build [...astro args] regenerate, then run \`astro build\`
363
539
  astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
364
540
 
365
- New project: npm create astroid@latest
541
+ New project: pnpm create astroid@latest
366
542
  `;
367
543
 
368
544
  async function main() {
@@ -0,0 +1,37 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** The Analytics Engine dataset binding name. */
3
+ export declare const ASTROID_VITALS_BINDING = "VITALS";
4
+ /** The dataset name, derived from the project key so two Astroid sites on one
5
+ * account don't write into the same table. */
6
+ export declare function astroidVitalsDataset(config: AstroidConfig): string;
7
+ /**
8
+ * Credentials the CWV READ-BACK needs — not the collection.
9
+ *
10
+ * The Analytics Engine SQL API is account-scoped and has no binding, so a token
11
+ * is unavoidable. Both are required together: an account id without a token (or
12
+ * vice versa) can't query, so partial provisioning reads as dormant exactly like
13
+ * every other Astroid module.
14
+ */
15
+ export declare const ASTROID_VITALS_SECRET_NAMES: readonly ["CF_ACCOUNT_ID", "CF_API_TOKEN"];
16
+ /**
17
+ * The public beacon, as a static `public/vitals.js`.
18
+ *
19
+ * A FILE rather than an inline script, and that is a CSP decision, not a style
20
+ * one: Astro hashes the scripts it processes into `script-src`, and an
21
+ * `is:inline` script carrying generated content cannot be hashed — it would be
22
+ * blocked. Served from `public/`, it is same-origin and covered by
23
+ * `script-src 'self'` with no policy change at all.
24
+ */
25
+ export declare function generateAstroidVitalsBeacon(config: AstroidConfig, beacon: string): {
26
+ path: string;
27
+ contents: string;
28
+ };
29
+ /**
30
+ * The health scan's CWV read-back, as source for the generated worker.
31
+ *
32
+ * Returns `undefined` — so the badge stays "not measured yet" — when the API
33
+ * credentials aren't set, the query fails, or there is no field data yet. Never
34
+ * throws: a failed vitals query must not take down the rest of the health scan,
35
+ * which is mostly cheap COUNTs that have nothing to do with it.
36
+ */
37
+ export declare function generateAstroidCwvQuery(config: AstroidConfig): string[];