create-astroid 0.1.2 → 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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Scaffold a new **Astroid** site — an editable, multi-editor Astro app on
4
4
  Cloudflare Workers — in one command.
5
5
 
6
6
  ```sh
7
- npm create astroid my-site
7
+ pnpm create astroid my-site
8
8
  ```
9
9
 
10
10
  > **Status: pre-1.0, experimental.** The scaffold's output will change between
@@ -28,7 +28,7 @@ Anything you don't pass is prompted for. In a non-TTY every prompt takes its
28
28
  default, so the command is CI-safe. The target directory must be empty.
29
29
 
30
30
  ```
31
- npm create astroid [directory] [options]
31
+ pnpm create astroid [directory] [options]
32
32
 
33
33
  --dir <path> Target directory (also the first positional)
34
34
  --name <name> Brand / site name
@@ -36,6 +36,15 @@ npm create astroid [directory] [options]
36
36
  --archetype <type> marketing | storefront | wholesale | portfolio
37
37
  --color <hex> Brand color
38
38
  --host <domain> Primary domain, e.g. example.com
39
+ --commerce <provider> square | stripe | fourthwall — also adds the queue
40
+ consumer, webhook receiver, and cron safety net
41
+ --map Self-hosted PMTiles/MapLibre location map
42
+ --pwa Installable PWA: a scoped service worker that never
43
+ caches /api/* or the editor, plus a manifest
44
+ --portal Customer/member portal: a second, isolated auth
45
+ instance plus role-gated routes
46
+ --realtime Live multi-editor editing: a per-page Durable Object
47
+ with presence, field sync, and a rich-text soft-lock
39
48
  -h, --help Show help
40
49
  -v, --version Show the version
41
50
  ```
@@ -44,13 +53,13 @@ npm create astroid [directory] [options]
44
53
 
45
54
  ```sh
46
55
  cd my-site
47
- npm install
48
- npx wrangler d1 create <name> # then paste the ids into wrangler.jsonc
49
- npm run doctor # validates config, bindings, generated files
50
- npm run dev
56
+ pnpm install
57
+ pnpm exec wrangler d1 create <name> # then paste the ids into wrangler.jsonc
58
+ pnpm doctor # validates config, bindings, generated files
59
+ pnpm dev
51
60
  ```
52
61
 
53
- `npm run doctor` flags any binding id you haven't filled in yet, plus generated
62
+ `pnpm doctor` flags any binding id you haven't filled in yet, plus generated
54
63
  files that have drifted from the config.
55
64
 
56
65
  ## How the pieces relate
package/index.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // `create-astroid` — scaffold a new Astroid site in one command:
5
5
  //
6
- // npm create astroid@latest my-site
7
- // npm create astroid@latest my-site -- --key coracle --name "Coracle Coffee" --color "#1f6f78" --host coracle.coffee
6
+ // pnpm create astroid@latest my-site
7
+ // pnpm create astroid@latest my-site --key coracle --name "Coracle Coffee" --color "#1f6f78" --host coracle.coffee
8
8
  //
9
9
  // It writes the floor: the `defineAstroid` config, the generated
10
10
  // schema/worker/middleware trio + wrangler.jsonc (via astroidjs), the Better Auth
@@ -14,10 +14,23 @@
14
14
  // uses, so a fresh project is already in sync.
15
15
 
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
+ import { createRequire } from "node:module";
17
18
  import { basename, dirname, join, resolve } from "node:path";
18
19
  import { createInterface } from "node:readline/promises";
19
20
  import { fileURLToPath } from "node:url";
20
- import { defineAstroid, generateAstroidProject, generateAstroidWrangler } from "astroidjs";
21
+ import {
22
+ ASTROID_ARCHETYPE_SECTIONS,
23
+ ASTROID_MAP_DEPENDENCIES,
24
+ defineAstroid,
25
+ generateAstroidEnvBindings,
26
+ generateAstroidPortalLocals,
27
+ generateAstroidProject,
28
+ generateAstroidCheckoutEnv,
29
+ generateAstroidRealtimeEnv,
30
+ generateAstroidScaffoldFiles,
31
+ generateAstroidSecretsEnv,
32
+ generateAstroidWrangler,
33
+ } from "astroidjs";
21
34
 
22
35
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
23
36
 
@@ -25,15 +38,19 @@ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
25
38
  // published package, so they ship as `_gitignore` / `_env.example`).
26
39
  const DOTFILE_RENAMES = { _gitignore: ".gitignore", "_env.example": ".env.example" };
27
40
 
28
- // Archetype → default editable home sections, when the user doesn't override.
29
- const ARCHETYPE_SECTIONS = {
30
- marketing: ["hero", "featureGrid", "cta", "contact"],
31
- storefront: ["hero", "marquee", "featured", "productGrid", "visit", "contact"],
32
- wholesale: ["hero", "featureGrid", "story", "contact"],
33
- portfolio: ["hero", "gallery", "story", "contact"],
34
- };
41
+ // Archetype → default editable home sections. Imported from astroidjs rather
42
+ // than duplicated here: as a literal in this file it could name a section that
43
+ // doesn't exist and nothing would say so (it did — `marquee`, `featured`,
44
+ // `story`, and `visit` had no component for months). Over there it's typed
45
+ // against the section catalog, so a stale name fails the build. See #277.
46
+ const ARCHETYPE_SECTIONS = ASTROID_ARCHETYPE_SECTIONS;
35
47
  const ARCHETYPES = Object.keys(ARCHETYPE_SECTIONS);
36
48
 
49
+ // Commerce backends astroidjs knows how to wire (webhook verifier + catalog
50
+ // event filter). Opt-in via `--commerce`; it also switches on the queue
51
+ // consumer, the webhook receiver, and the cron safety net.
52
+ const COMMERCE_PROVIDERS = ["square", "stripe", "fourthwall"];
53
+
37
54
  // --- args ------------------------------------------------------------------
38
55
  function parseArgs(argv) {
39
56
  const flags = {};
@@ -53,6 +70,51 @@ function parseArgs(argv) {
53
70
  const slugify = (s) =>
54
71
  s.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
55
72
 
73
+ // --- toolkit versions ------------------------------------------------------
74
+
75
+ /**
76
+ * The `astroidjs` + `louise-toolkit` ranges to write into the scaffold.
77
+ *
78
+ * DERIVED from this package's own resolved dependencies rather than hard-coded
79
+ * in template/package.json. A literal there is a second place to remember on
80
+ * every release, and when it rots the failure is silent and total: the template
81
+ * imported `astroidjs/astro` while pinning `^0.1.0`, a range whose newest match
82
+ * had no such export, so every scaffolded project died before Astro loaded its
83
+ * config. CI could not see it — the clean-room smoke test pins both packages to
84
+ * tarballs via pnpm `overrides`, which is exactly what erases these ranges.
85
+ *
86
+ * `pnpm pack` rewrites `workspace:*` to the concrete version, so in a PUBLISHED
87
+ * create-astroid the declared dep is already exact and we just widen it to a
88
+ * caret. Run from the workspace it is still `workspace:*`, so fall back to the
89
+ * version of the copy actually resolved on disk — which is what the scaffold
90
+ * would install anyway.
91
+ *
92
+ * Caret on a 0.x is minor-locked (`^0.2.0` := `>=0.2.0 <0.3.0`), which is the
93
+ * behaviour we want while the toolkit is pre-1.0 and marks breaking changes as
94
+ * minors: patches flow, a breaking minor does not.
95
+ */
96
+ function toolkitRanges() {
97
+ const req = createRequire(import.meta.url);
98
+ const self = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
99
+ const ranges = {};
100
+ for (const name of ["astroidjs", "louise-toolkit"]) {
101
+ const declared = self.dependencies?.[name];
102
+ let version = declared && !declared.startsWith("workspace:") ? declared : undefined;
103
+ if (!version) {
104
+ // Both packages export `./package.json`, so this resolves the real copy.
105
+ version = JSON.parse(readFileSync(req.resolve(`${name}/package.json`), "utf8")).version;
106
+ }
107
+ if (!version) {
108
+ throw new Error(
109
+ `create-astroid could not determine the ${name} version to scaffold with. ` +
110
+ "This is a packaging fault — please file an issue rather than editing the scaffold by hand.",
111
+ );
112
+ }
113
+ ranges[name] = `^${version}`;
114
+ }
115
+ return ranges;
116
+ }
117
+
56
118
  async function prompt(question, fallback) {
57
119
  if (!process.stdin.isTTY) return fallback;
58
120
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -100,6 +162,19 @@ function astroidConfigSource(config) {
100
162
  ` colors: { brand: ${JSON.stringify(config.theme.colors.brand)} },`,
101
163
  " },",
102
164
  ` sections: ${JSON.stringify(config.sections)},`,
165
+ ...(config.commerce
166
+ ? [` commerce: { provider: ${JSON.stringify(config.commerce.provider)} },`]
167
+ : []),
168
+ // Must be emitted, for the same reason the portal is: `astroid generate`
169
+ // rebuilds the middleware and CSP from THIS file, so a config that dropped
170
+ // `modules` would regenerate a project missing whatever they contribute —
171
+ // for the map, a policy without `worker-src blob:`, which renders an empty
172
+ // canvas with no obvious cause.
173
+ ...(config.modules?.length ? [` modules: ${JSON.stringify(config.modules)},`] : []),
174
+ // Must be emitted: `astroid generate` rebuilds the middleware from THIS
175
+ // file, so a config that omitted the portal would regenerate a middleware
176
+ // with no guard while src/portal-auth.ts sat there unused.
177
+ ...(config.portal?.enabled ? [" portal: { enabled: true },"] : []),
103
178
  ' deploy: { platform: "cloudflare" },',
104
179
  "});",
105
180
  "",
@@ -116,7 +191,7 @@ function write(destDir, relPath, contents) {
116
191
  const USAGE = `Scaffold a new Astroid site — an editable Astro app on Cloudflare Workers.
117
192
 
118
193
  Usage:
119
- npm create astroid [directory] [options]
194
+ pnpm create astroid [directory] [options]
120
195
 
121
196
  Options:
122
197
  --dir <path> Target directory (also accepted as the first positional)
@@ -125,6 +200,15 @@ Options:
125
200
  --archetype <type> ${ARCHETYPES.join(" | ")} (default: marketing)
126
201
  --color <hex> Brand color (default: #5b4bff)
127
202
  --host <domain> Primary domain, e.g. example.com
203
+ --commerce <provider> ${COMMERCE_PROVIDERS.join(" | ")}
204
+ Also adds the queue consumer, webhook receiver, and cron
205
+ --map Add the self-hosted PMTiles/MapLibre location map
206
+ --pwa Add an installable PWA: a scoped service worker that
207
+ never caches /api/* or the editor, plus a manifest
208
+ --realtime Add live multi-editor editing: a per-page Durable Object
209
+ with presence, field sync, and a rich-text soft-lock
210
+ --portal Add a customer/member portal: a second, isolated auth
211
+ instance plus role-gated routes
128
212
  -h, --help Show this help
129
213
  -v, --version Show the create-astroid version
130
214
 
@@ -159,6 +243,33 @@ async function main() {
159
243
  const archetype = ARCHETYPES.includes(archetypeRaw) ? archetypeRaw : "marketing";
160
244
  const color = flags.color || (await prompt("Brand color (hex)", "#5b4bff"));
161
245
  const host = flags.host && flags.host !== true ? flags.host : undefined;
246
+ // Portal + commerce are opt-in and unprompted: each pulls in real
247
+ // infrastructure a plain marketing site should not carry.
248
+ const portal = flags.portal === true || flags.portal === "true";
249
+ // The map module is opt-in and pulls real weight (maplibre-gl is ~1 MB), so
250
+ // it is never on by default.
251
+ const map = flags.map === true || flags.map === "true";
252
+ // Opt-in: a service worker is a caching layer over a CMS-edited site, so it
253
+ // is never on unless asked for.
254
+ const pwa = flags.pwa === true || flags.pwa === "true";
255
+ // Opt-in: realtime provisions a Durable Object, which is real infrastructure a
256
+ // single-editor site has no use for.
257
+ const realtime = flags.realtime === true || flags.realtime === "true";
258
+ const modules = [
259
+ ...(map ? ["map"] : []),
260
+ ...(pwa ? ["pwa"] : []),
261
+ ...(realtime ? ["realtime"] : []),
262
+ ];
263
+ // Commerce is opt-in and unprompted: it pulls in a queue consumer, a webhook
264
+ // receiver, and a cron, none of which a plain marketing site should carry.
265
+ const commerceRaw = typeof flags.commerce === "string" ? flags.commerce.toLowerCase() : undefined;
266
+ const commerce = COMMERCE_PROVIDERS.includes(commerceRaw) ? commerceRaw : undefined;
267
+ if (commerceRaw && !commerce) {
268
+ process.stderr.write(
269
+ `create-astroid: unknown --commerce provider "${commerceRaw}" (expected ${COMMERCE_PROVIDERS.join(" | ")})\n`,
270
+ );
271
+ process.exit(1);
272
+ }
162
273
 
163
274
  if (existsSync(dir) && readdirSync(dir).length > 0) {
164
275
  process.stderr.write(`create-astroid: target directory is not empty: ${dir}\n`);
@@ -172,21 +283,72 @@ async function main() {
172
283
  ...(host ? { hosts: [host] } : {}),
173
284
  theme: { name, colors: { brand: color } },
174
285
  sections: ARCHETYPE_SECTIONS[archetype],
286
+ ...(commerce ? { commerce: { provider: commerce } } : {}),
287
+ ...(portal ? { portal: { enabled: true } } : {}),
288
+ // ONE array, built from every enabled flag. Two separate `...(x ? {modules}
289
+ // : {})` spreads would let the later one overwrite the earlier, silently
290
+ // dropping a module whenever both were passed.
291
+ ...(modules.length > 0 ? { modules } : {}),
175
292
  deploy: { platform: "cloudflare" },
176
293
  });
177
294
 
178
295
  const siteUrl = host ? `https://${host}` : `https://${key}.workers.dev`;
296
+ const envBindings = generateAstroidEnvBindings(config);
297
+ const portalLocals = generateAstroidPortalLocals(config);
298
+ // The realtime DO namespace, or nothing — same rule as the queue bindings: a
299
+ // declaration is a promise, so never type a binding wrangler.jsonc won't create.
300
+ const realtimeEnv = generateAstroidRealtimeEnv(config);
301
+ // The Square Web Payments public vars, or nothing.
302
+ const checkoutEnv = generateAstroidCheckoutEnv(config);
179
303
  const tokens = {
180
304
  KEY: key,
181
305
  BRAND_NAME: name,
182
306
  BRAND_COLOR: color,
183
307
  ARCHETYPE: archetype,
184
308
  SITE_URL: siteUrl,
309
+ // Extra CloudflareEnv members the queue pipeline needs, or nothing. A
310
+ // declaration is a promise — a marketing site must not claim a binding its
311
+ // wrangler.jsonc never creates.
312
+ ASTROID_ENV_BINDINGS: [envBindings, realtimeEnv, checkoutEnv].filter(Boolean).join("\n")
313
+ ? `\n${[envBindings, realtimeEnv, checkoutEnv].filter(Boolean).join("\n")}`
314
+ : "",
315
+ // The portal session on App.Locals, or nothing — a project that types a
316
+ // local it never sets invites a null-check nobody needs.
317
+ ASTROID_PORTAL_LOCALS: portalLocals ? `\n${portalLocals}` : "",
318
+ // Placeholder-seeded secrets for whichever modules this project enabled, so
319
+ // a fresh clone has a COMPLETE binding set that all reads as unconfigured —
320
+ // every module takes its dormant path deliberately rather than tripping over
321
+ // an undefined binding. Empty for a project with no credentialed module.
322
+ ASTROID_MODULE_SECRETS: generateAstroidSecretsEnv(config),
185
323
  };
186
324
 
187
325
  // 1. The static floor (Astro app, auth seam, config files) with tokens filled.
188
326
  copyTemplate(TEMPLATE_DIR, dir, tokens);
189
327
 
328
+ // 1b. Toolkit versions + module dependencies, merged into the copied package.json.
329
+ //
330
+ // Merged by PARSING the file rather than substituting a token into it:
331
+ // a `__TOKEN__` inside a JSON object makes template/package.json invalid
332
+ // JSON, and everything that scans a repo for manifests — Snyk, Dependabot,
333
+ // editors, workspace tooling — parses it and fails. (It did.)
334
+ //
335
+ // The `astroidjs` / `louise-toolkit` ranges are DERIVED (see
336
+ // `toolkitRanges`), never taken from template/package.json — a hand-written
337
+ // range there silently rots into a scaffold that can't build. The literals
338
+ // it still carries are placeholders that keep the file valid JSON.
339
+ //
340
+ // Only the enabled modules contribute the rest: nobody installs a megabyte
341
+ // of mapping library for a site with no map.
342
+ const extraDeps = { ...toolkitRanges(), ...(map ? ASTROID_MAP_DEPENDENCIES : {}) };
343
+ {
344
+ const pkgPath = join(dir, "package.json");
345
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
346
+ pkg.dependencies = Object.fromEntries(
347
+ Object.entries({ ...pkg.dependencies, ...extraDeps }).sort(([a], [b]) => a.localeCompare(b)),
348
+ );
349
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
350
+ }
351
+
190
352
  // 2. The typed config the generators + the app read.
191
353
  write(dir, "astroid.config.ts", astroidConfigSource(config));
192
354
 
@@ -194,6 +356,31 @@ async function main() {
194
356
  for (const file of generateAstroidProject(config)) write(dir, file.path, file.contents);
195
357
  write(dir, "wrangler.jsonc", generateAstroidWrangler(config));
196
358
 
359
+ // 3b. Every scaffold-once module file this config implies — the queue seam and
360
+ // webhook receivers, the portfolio gallery page, the PWA service worker +
361
+ // manifest + headers, the map tile route + embed, the portal's second auth
362
+ // instance and its mounted catch-all.
363
+ //
364
+ // ONE list, imported from astroidjs, because `astroid generate` writes the
365
+ // same files when a config gains a module after scaffold. Hand-listing them
366
+ // here was the only way to produce them, so editing the config — the entire
367
+ // premise of the framework — regenerated a trio importing `./queue.js` and
368
+ // `./portal-auth.js` that nothing had written, and `astroid doctor` called
369
+ // it healthy. Sharing the list is what keeps the two paths honest.
370
+ for (const file of generateAstroidScaffoldFiles(config)) {
371
+ if (file.apply === "append-once") {
372
+ // `public/_headers` accumulates a stanza per module rather than being owned
373
+ // by one, so append instead of overwriting a sibling module's block.
374
+ const abs = join(dir, file.path);
375
+ mkdirSync(dirname(abs), { recursive: true });
376
+ const current = existsSync(abs) ? readFileSync(abs, "utf8") : "";
377
+ if (file.marker && current.includes(file.marker)) continue;
378
+ writeFileSync(abs, current + file.contents);
379
+ continue;
380
+ }
381
+ write(dir, file.path, file.contents);
382
+ }
383
+
197
384
  // 4. The Better Auth migration (louise-toolkit) — auth tables are fenced out of
198
385
  // drizzle-kit, so they're generated rather than diffed from schema.ts. Loaded
199
386
  // dynamically: it pulls in `better-auth` (an optional peer), which may not be
@@ -203,13 +390,35 @@ async function main() {
203
390
  try {
204
391
  const { generateAuthSchemaSql } = await import("louise-toolkit/auth");
205
392
  write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql());
393
+ // The portal's own auth tables. A SECOND set, prefixed — the two instances
394
+ // share one D1 but never a row, so a portal account can't sign into the
395
+ // studio and an editor doesn't appear in the portal. Without this migration
396
+ // the portal builds fine and fails on the first sign-in.
397
+ if (config.portal?.enabled) {
398
+ write(
399
+ dir,
400
+ "migrations/0002_portal_auth.sql",
401
+ generateAuthSchemaSql({ tablePrefix: "portal_" }),
402
+ );
403
+ }
206
404
  authMigrationOk = true;
207
405
  } catch {
208
406
  write(
209
407
  dir,
210
408
  "migrations/0001_auth.sql",
211
- "-- Better Auth tables — generate after install:\n-- npx louise gen-auth-schema --out migrations/0001_auth.sql\n",
409
+ "-- Better Auth tables — generate after install:\n-- pnpm exec louise gen-auth-schema --out migrations/0001_auth.sql\n",
212
410
  );
411
+ // Same stub for the portal's prefixed set. Without it a portal scaffold
412
+ // looks complete, builds, and fails on the first sign-in with a missing
413
+ // table — the one failure mode a stub exists to prevent.
414
+ if (config.portal?.enabled) {
415
+ write(
416
+ dir,
417
+ "migrations/0002_portal_auth.sql",
418
+ "-- Portal Better Auth tables (prefixed) — generate after install:\n" +
419
+ "-- pnpm exec louise gen-auth-schema --table-prefix portal_ --out migrations/0002_portal_auth.sql\n",
420
+ );
421
+ }
213
422
  }
214
423
 
215
424
  const rel = dir === process.cwd() ? "." : basename(dir);
@@ -221,12 +430,32 @@ async function main() {
221
430
  "Next steps:",
222
431
  ` cd ${rel}`,
223
432
  " pnpm install",
433
+ // The auth-migration fallback belongs HERE, in sequence, not in a note
434
+ // printed after the list. It has to run before `d1 migrations apply`, and
435
+ // a correction that appears below an ordered list is a correction most
436
+ // people execute the list without reading: the stub left no `user` table,
437
+ // so `seed:editors` failed with `no such table: user` and the very first
438
+ // instruction anyone follows was the one that broke.
439
+ ...(authMigrationOk
440
+ ? []
441
+ : [
442
+ " # generate the Better Auth migration (it could not be written at scaffold",
443
+ " # time — `louise` is on your path once the install above finishes):",
444
+ " pnpm exec louise gen-auth-schema --out migrations/0001_auth.sql",
445
+ ...(config.portal?.enabled
446
+ ? [
447
+ " pnpm exec louise gen-auth-schema --table-prefix portal_ \\",
448
+ " --out migrations/0002_portal_auth.sql",
449
+ ]
450
+ : []),
451
+ ]),
224
452
  " # provision the Cloudflare bindings, then fill the ids in wrangler.jsonc:",
225
453
  " wrangler d1 create " + key,
226
454
  " wrangler r2 bucket create " + key + "-media",
227
455
  " wrangler kv namespace create RL && wrangler kv namespace create DRAFTS",
228
- " # apply migrations + seed your first editor:",
456
+ " # apply migrations, seed the home page + your first editor:",
229
457
  " wrangler d1 migrations apply DB --remote",
458
+ " wrangler d1 execute DB --remote --file seed/home.seed.sql",
230
459
  " OWNER_EMAIL=you@example.com pnpm seed:editors",
231
460
  " # develop / ship:",
232
461
  " pnpm dev # astroid dev (regenerates, then astro dev)",
@@ -237,8 +466,8 @@ async function main() {
237
466
  );
238
467
  if (!authMigrationOk) {
239
468
  process.stdout.write(
240
- "Note: generate the Better Auth migration after install:\n" +
241
- " npx louise gen-auth-schema --out migrations/0001_auth.sql\n\n",
469
+ "Note: the Better Auth migration is a stub — the `gen-auth-schema` step above\n" +
470
+ "fills it in. Skipping it leaves no `user` table, and `seed:editors` will fail.\n\n",
242
471
  );
243
472
  }
244
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astroid",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new Astroid site — an editable, multi-editor Astro app on Cloudflare Workers — in one command.",
5
5
  "keywords": [
6
6
  "astro",
@@ -30,8 +30,10 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "astroidjs": "0.1.2",
34
- "louise-toolkit": "0.15.0"
33
+ "@better-auth/passkey": "^1.6.23",
34
+ "better-auth": "^1.6.23",
35
+ "astroidjs": "0.2.0",
36
+ "louise-toolkit": "0.16.0"
35
37
  },
36
38
  "engines": {
37
39
  "node": ">=24.0.0"
@@ -16,6 +16,12 @@ cp .env.example .dev.vars # local secrets for `astro dev`; fill SESSION_SECRET
16
16
  pnpm dev # astroid dev: regenerate, then astro dev
17
17
  ```
18
18
 
19
+ > **Previewing the built worker?** `pnpm dev` (astro dev) serves on localhost, so
20
+ > an empty `SESSION_SECRET` is fine there. A local `wrangler dev` against the
21
+ > built `dist/` output routes the request through your `hosts` domain instead of
22
+ > localhost, so the editor routes need a real `SESSION_SECRET` in `.dev.vars` —
23
+ > otherwise sign-in 500s with "SESSION_SECRET is not configured".
24
+
19
25
  ## Deploy
20
26
 
21
27
  Astroid wrote `wrangler.jsonc` with placeholder binding ids. Pick a path to
@@ -58,6 +64,10 @@ wrangler d1 execute DB --file seed/home.seed.sql --remote
58
64
  OWNER_EMAIL=you@example.com pnpm seed:editors
59
65
  ```
60
66
 
67
+ The seeded page renders immediately. In-editor **search** indexes on publish, so
68
+ a raw-SQL-seeded row isn't searchable until you publish an edit or backfill once
69
+ with `POST /api/louise/pages/reindex` (signed in).
70
+
61
71
  ## Editors & auth
62
72
 
63
73
  Editors sign in with a magic link (passkeys supported). The allowlist is
@@ -1,7 +1,22 @@
1
1
  # Local dev secrets (wrangler reads .dev.vars; copy this there for `astro dev`).
2
2
  # In production these are set with `wrangler secret put`, NOT committed.
3
+ #
4
+ # Astroid's convention: an unprovisioned secret leaves its feature DORMANT, never
5
+ # broken. A secret that is empty — or still holds the DUMMY_REPLACE_ME sentinel —
6
+ # reads as "not configured", so a fresh clone boots and runs with no external
7
+ # accounts at all. Replace a value to switch that feature on.
3
8
 
4
9
  # Signs Better Auth sessions. Generate: `openssl rand -base64 32`.
10
+ #
11
+ # Empty is fine under `pnpm dev` — astro dev serves on localhost, where a fixed
12
+ # dev secret keeps the sign-in → session loop working. Any deployed host fails
13
+ # closed rather than signing sessions with a known value.
14
+ #
15
+ # BUT a local `wrangler dev` preview of the BUILT worker is not localhost as far
16
+ # as the app is concerned: wrangler routes the request through your `hosts`
17
+ # domain (astroid.config.ts), so the dev fallback never fires and every editor
18
+ # route 500s with "SESSION_SECRET is not configured". Set a value here before
19
+ # previewing the built worker that way.
5
20
  SESSION_SECRET=
6
21
 
7
22
  # The first editor's email — seeded as an admin `user` row by `pnpm seed:editors`,
@@ -10,3 +25,24 @@ OWNER_EMAIL=you@example.com
10
25
 
11
26
  # `from` address for magic-link + notification email (Cloudflare Email Sending).
12
27
  MAIL_FROM=no-reply@__KEY__.example
28
+
29
+ # Turnstile captcha on the magic-link endpoint. Dormant as shipped: the sentinel
30
+ # secret below plus Cloudflare's always-passing TEST site key. Captcha only
31
+ # enforces once BOTH are real — provisioning one half can't lock you out of your
32
+ # own sign-in. Get a real pair at dash.cloudflare.com → Turnstile.
33
+ TURNSTILE_SECRET=DUMMY_REPLACE_ME
34
+ TURNSTILE_SITE_KEY=1x00000000000000000000AA
35
+ __ASTROID_MODULE_SECRETS__
36
+
37
+ # --- web vitals -----------------------------------------------------------
38
+ #
39
+ # ONLY needed to read Core Web Vitals back out. Collection works without them:
40
+ # the beacon posts to /api/louise/vitals and the Worker writes to the Analytics
41
+ # Engine dataset regardless. Querying the p75 back out goes through the SQL API,
42
+ # which is account-scoped and has no binding — hence a token.
43
+ #
44
+ # Left as the sentinel, the daily health scan simply skips the query and the
45
+ # Health panel shows "not measured yet". Create a token with Account
46
+ # Analytics:Read at dash.cloudflare.com → My Profile → API Tokens.
47
+ CF_ACCOUNT_ID=DUMMY_REPLACE_ME
48
+ CF_API_TOKEN=DUMMY_REPLACE_ME
@@ -1,8 +1,11 @@
1
1
  // @ts-check
2
2
  import cloudflare from "@astrojs/cloudflare";
3
+ import { cacheCloudflare } from "@astrojs/cloudflare/cache";
3
4
  import solid from "@astrojs/solid-js";
4
5
  import tailwindcss from "@tailwindcss/vite";
6
+ import { ASTROID_VITE_BUILD, astroidSecurity } from "astroidjs/astro";
5
7
  import { defineConfig } from "astro/config";
8
+ import astroidConfig from "./astroid.config.ts";
6
9
 
7
10
  // SSR (`output: server`) because Louise renders per-request edit affordances and
8
11
  // reads pages from D1. Solid islands power the editor UI (ADR 0001). Tailwind v4 +
@@ -14,17 +17,31 @@ export default defineConfig({
14
17
  output: "server",
15
18
  adapter: cloudflare(),
16
19
  integrations: [solid()],
17
- vite: { plugins: [tailwindcss()] },
18
- // Content-Security-Policy. Astro hashes every processed script + style and emits
19
- // a `content-security-policy` response header on on-demand (SSR) pages — which is
20
- // all of ours. The generated src/middleware.ts (createLouiseMiddleware) then
21
- // rewrites `style-src` to `'self' 'unsafe-inline'` so Louise's data-driven
22
- // `style=""` carriers and the editor's runtime-injected <style> are allowed, and
23
- // permits the inlined `data:` brand font. This is why the inline scripts here
24
- // (login.astro, LouiseEdit.astro) avoid is:inline/define:vars — those can't be
25
- // hashed and would be blocked.
20
+ vite: {
21
+ plugins: [tailwindcss()],
22
+ build: { ...ASTROID_VITE_BUILD },
23
+ },
24
+ // Route caching (ADR 0004). This provider is what turns `Astro.cache.set(...)`
25
+ // into a `Cloudflare-CDN-Cache-Control` header — which the generated worker's
26
+ // `withEdgeCache` layer reads as its "store this" signal and then STRIPS, so
27
+ // Cloudflare's own cookie-blind edge cache never sees it.
26
28
  //
27
- // Using Square Web Payments? Allow its SDK host in script-src:
28
- // security: { csp: { scriptDirective: { resources: ["'self'", "https://web.squarecdn.com"] } } }
29
- security: { csp: true },
29
+ // Opt-in per response: a route that never calls `Astro.cache.set` (or calls
30
+ // `set(false)`, as an edit-mode render does) goes out `no-store`. Nothing
31
+ // personalized is ever cached. Published pages opt in from index.astro, gated
32
+ // on the ASTROID_EDGE_CACHE var — which is "false" until you have walked the
33
+ // activation runbook on a preview deploy.
34
+ cache: { provider: cacheCloudflare() },
35
+ // Content-Security-Policy, composed by Astroid from your config: it derives the
36
+ // allowed origins from the modules you enabled (commerce provider SDKs,
37
+ // captcha) and adds the hash of Solid's hydration bootstrap, which Astro does
38
+ // not hash itself. Astro owns `script-src` (every script it processes is
39
+ // hashed, so no 'unsafe-inline'); the generated src/middleware.ts rewrites only
40
+ // `style-src`, because Louise's data-driven `style=""` carriers need
41
+ // 'unsafe-inline' and a hash in that directive would void it.
42
+ //
43
+ // This is why the inline scripts here (login.astro, LouiseEdit.astro) avoid
44
+ // is:inline/define:vars — those can't be hashed and would be blocked. Need
45
+ // another origin? Add it to `security.cspOrigins` in astroid.config.ts.
46
+ security: astroidSecurity(astroidConfig),
30
47
  });
@@ -16,11 +16,14 @@
16
16
  "@astrojs/cloudflare": "^14.1.3",
17
17
  "@astrojs/solid-js": "^7.0.1",
18
18
  "@better-auth/passkey": "^1.6.23",
19
+ "@prosekit/pm": "^0.1.18",
20
+ "@tanstack/solid-query": "^5.101.2",
19
21
  "astro": "^7.0.9",
20
- "astroidjs": "^0.1.0",
22
+ "astroidjs": "0.0.0-replaced-at-scaffold",
21
23
  "better-auth": "^1.6.23",
22
24
  "drizzle-orm": "^0.45.2",
23
- "louise-toolkit": "^0.14.0",
25
+ "louise-toolkit": "0.0.0-replaced-at-scaffold",
26
+ "prosekit": "^0.21.4",
24
27
  "solid-js": "^1.9.14",
25
28
  "zod": "^4.4.3"
26
29
  },
@@ -0,0 +1,38 @@
1
+ # pnpm configuration for this project.
2
+ #
3
+ # Not a workspace — pnpm 10+ reads its settings from THIS file even for a single
4
+ # package, and `overrides` in package.json is silently ignored.
5
+
6
+ # Postinstall-script approvals. Without these `pnpm install` FAILS outright with
7
+ # ERR_PNPM_IGNORED_BUILDS, because pnpm refuses to run a dependency's build
8
+ # script until you say so — and esbuild and workerd both need theirs. A scaffold
9
+ # whose very first documented command errors is not a scaffold.
10
+ #
11
+ # `sharp: false` denies its heavy native build on purpose. Nothing here imports
12
+ # it: image work goes through the Cloudflare Images binding and the adapter's
13
+ # workerd image service. It arrives only as an OPTIONAL dependency of astro, so
14
+ # denying it skips the build rather than prompting on every install.
15
+ allowBuilds:
16
+ esbuild: true
17
+ workerd: true
18
+ sharp: false
19
+
20
+ # Two advisories, both reaching a scaffold through `better-auth`'s dev-tooling
21
+ # transitives. Neither shows up in `pnpm audit` (GitHub's DB); Snyk carries them.
22
+ #
23
+ # esbuild — SNYK-JS-ESBUILD-17750822, "resources downloaded over insecure
24
+ # protocol", CVSS 9.2, fixed in 0.28.1. Two paths reach a vulnerable copy:
25
+ # drizzle-kit's own `esbuild ^0.25.4`, and the deprecated `@esbuild-kit/esm-loader`
26
+ # it still ships (whose core-utils pins 0.18.20) — so this is blanket rather than
27
+ # scoped to one parent. Astro and Vite already want 0.28.x, so it unifies the tree
28
+ # rather than forcing an odd version. Verified drizzle-kit still transpiles a
29
+ # TypeScript drizzle.config.ts afterwards.
30
+ #
31
+ # ws — CVE-2026-62389, unbounded resource allocation, CVSS 8.7, fixed in 8.21.1.
32
+ #
33
+ # Drop these once better-auth's transitives move past them.
34
+ #
35
+ # KEEP THIS KEY LAST: the CI smoke test appends its own entries here.
36
+ overrides:
37
+ esbuild: "^0.28.1"
38
+ ws: "^8.21.1"