create-astroid 0.1.2 → 0.3.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 +16 -7
- package/index.mjs +249 -16
- package/package.json +5 -3
- package/template/README.md +10 -0
- package/template/_env.example +36 -0
- package/template/astro.config.mjs +29 -12
- package/template/package.json +5 -2
- package/template/pnpm-workspace.yaml +38 -0
- package/template/scripts/seed-editors.mjs +2 -2
- package/template/seed/home.seed.sql +48 -1
- package/template/src/auth.ts +24 -14
- package/template/src/components/LouiseEdit.astro +46 -3
- package/template/src/env.d.ts +54 -5
- package/template/src/layouts/Site.astro +84 -4
- package/template/src/pages/contact.astro +122 -0
- package/template/src/pages/index.astro +78 -5
- package/template/src/pages/login.astro +52 -2
- package/template/src/pages/robots.txt.ts +36 -0
- package/template/src/pages/sitemap.xml.ts +40 -0
- package/template/src/styles/site.css +9 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
`
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
@@ -202,14 +389,40 @@ async function main() {
|
|
|
202
389
|
let authMigrationOk = false;
|
|
203
390
|
try {
|
|
204
391
|
const { generateAuthSchemaSql } = await import("louise-toolkit/auth");
|
|
205
|
-
|
|
392
|
+
// The EDITOR instance's tables — `louise_`-prefixed (the editor convention),
|
|
393
|
+
// leaving the unprefixed `user`/`session` names free for a second/portal
|
|
394
|
+
// instance. Must match the `tablePrefix` in src/auth.ts and the `louise_user`
|
|
395
|
+
// table the generated `editorsRoute` reads.
|
|
396
|
+
write(dir, "migrations/0001_auth.sql", generateAuthSchemaSql({ tablePrefix: "louise_" }));
|
|
397
|
+
// The portal's own auth tables. A SECOND set, prefixed — the two instances
|
|
398
|
+
// share one D1 but never a row, so a portal account can't sign into the
|
|
399
|
+
// studio and an editor doesn't appear in the portal. Without this migration
|
|
400
|
+
// the portal builds fine and fails on the first sign-in.
|
|
401
|
+
if (config.portal?.enabled) {
|
|
402
|
+
write(
|
|
403
|
+
dir,
|
|
404
|
+
"migrations/0002_portal_auth.sql",
|
|
405
|
+
generateAuthSchemaSql({ tablePrefix: "portal_" }),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
206
408
|
authMigrationOk = true;
|
|
207
409
|
} catch {
|
|
208
410
|
write(
|
|
209
411
|
dir,
|
|
210
412
|
"migrations/0001_auth.sql",
|
|
211
|
-
"-- Better Auth tables — generate after install:\n--
|
|
413
|
+
"-- Better Auth tables (editor, louise_ prefix) — generate after install:\n-- pnpm exec louise gen-auth-schema --table-prefix louise_ --out migrations/0001_auth.sql\n",
|
|
212
414
|
);
|
|
415
|
+
// Same stub for the portal's prefixed set. Without it a portal scaffold
|
|
416
|
+
// looks complete, builds, and fails on the first sign-in with a missing
|
|
417
|
+
// table — the one failure mode a stub exists to prevent.
|
|
418
|
+
if (config.portal?.enabled) {
|
|
419
|
+
write(
|
|
420
|
+
dir,
|
|
421
|
+
"migrations/0002_portal_auth.sql",
|
|
422
|
+
"-- Portal Better Auth tables (prefixed) — generate after install:\n" +
|
|
423
|
+
"-- pnpm exec louise gen-auth-schema --table-prefix portal_ --out migrations/0002_portal_auth.sql\n",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
213
426
|
}
|
|
214
427
|
|
|
215
428
|
const rel = dir === process.cwd() ? "." : basename(dir);
|
|
@@ -221,12 +434,32 @@ async function main() {
|
|
|
221
434
|
"Next steps:",
|
|
222
435
|
` cd ${rel}`,
|
|
223
436
|
" pnpm install",
|
|
437
|
+
// The auth-migration fallback belongs HERE, in sequence, not in a note
|
|
438
|
+
// printed after the list. It has to run before `d1 migrations apply`, and
|
|
439
|
+
// a correction that appears below an ordered list is a correction most
|
|
440
|
+
// people execute the list without reading: the stub left no `user` table,
|
|
441
|
+
// so `seed:editors` failed with `no such table: user` and the very first
|
|
442
|
+
// instruction anyone follows was the one that broke.
|
|
443
|
+
...(authMigrationOk
|
|
444
|
+
? []
|
|
445
|
+
: [
|
|
446
|
+
" # generate the Better Auth migration (it could not be written at scaffold",
|
|
447
|
+
" # time — `louise` is on your path once the install above finishes):",
|
|
448
|
+
" pnpm exec louise gen-auth-schema --table-prefix louise_ --out migrations/0001_auth.sql",
|
|
449
|
+
...(config.portal?.enabled
|
|
450
|
+
? [
|
|
451
|
+
" pnpm exec louise gen-auth-schema --table-prefix portal_ \\",
|
|
452
|
+
" --out migrations/0002_portal_auth.sql",
|
|
453
|
+
]
|
|
454
|
+
: []),
|
|
455
|
+
]),
|
|
224
456
|
" # provision the Cloudflare bindings, then fill the ids in wrangler.jsonc:",
|
|
225
457
|
" wrangler d1 create " + key,
|
|
226
458
|
" wrangler r2 bucket create " + key + "-media",
|
|
227
459
|
" wrangler kv namespace create RL && wrangler kv namespace create DRAFTS",
|
|
228
|
-
" # apply migrations +
|
|
460
|
+
" # apply migrations, seed the home page + your first editor:",
|
|
229
461
|
" wrangler d1 migrations apply DB --remote",
|
|
462
|
+
" wrangler d1 execute DB --remote --file seed/home.seed.sql",
|
|
230
463
|
" OWNER_EMAIL=you@example.com pnpm seed:editors",
|
|
231
464
|
" # develop / ship:",
|
|
232
465
|
" pnpm dev # astroid dev (regenerates, then astro dev)",
|
|
@@ -237,8 +470,8 @@ async function main() {
|
|
|
237
470
|
);
|
|
238
471
|
if (!authMigrationOk) {
|
|
239
472
|
process.stdout.write(
|
|
240
|
-
"Note:
|
|
241
|
-
"
|
|
473
|
+
"Note: the Better Auth migration is a stub — the `gen-auth-schema` step above\n" +
|
|
474
|
+
"fills it in. Skipping it leaves no `user` table, and `seed:editors` will fail.\n\n",
|
|
242
475
|
);
|
|
243
476
|
}
|
|
244
477
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astroid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
"
|
|
34
|
-
"
|
|
33
|
+
"@better-auth/passkey": "^1.6.23",
|
|
34
|
+
"better-auth": "^1.6.23",
|
|
35
|
+
"louise-toolkit": "0.17.0",
|
|
36
|
+
"astroidjs": "0.3.0"
|
|
35
37
|
},
|
|
36
38
|
"engines": {
|
|
37
39
|
"node": ">=24.0.0"
|
package/template/README.md
CHANGED
|
@@ -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
|
package/template/_env.example
CHANGED
|
@@ -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: {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
// `
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
});
|
package/template/package.json
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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"
|