astroidjs 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # astroidjs
2
+
3
+ **Astroid** — an opinionated meta-framework over
4
+ [Louise Toolkit](https://github.com/bowenlabs/louise-toolkit/tree/main/packages/louise)
5
+ and Astro for building editable, multi-editor sites on Cloudflare Workers.
6
+
7
+ > **Status: pre-1.0, experimental.** The API will change between minor versions —
8
+ > pin an exact version if you depend on it. Astroid lives in the same workspace as
9
+ > Louise so its opinions co-evolve with the toolkit.
10
+
11
+ ## What it is
12
+
13
+ Louise is the unopinionated toolkit — primitives you assemble by hand. Astroid is
14
+ the opinionated preset on top: a theme system, a section library, and a single
15
+ config that generates the Louise wiring (worker routes, middleware, schema,
16
+ theme) a site would otherwise hand-write per repo.
17
+
18
+ ```
19
+ Astro → renderer / router / build
20
+ Louise → unopinionated primitives + framework glue (louise-toolkit)
21
+ Astroid → opinions: theme, sections, config, scaffold (astroidjs)
22
+ ```
23
+
24
+ ## Design rule
25
+
26
+ Dependencies flow one way: **`astroidjs` → `louise-toolkit`, never the reverse.**
27
+ Louise must never import Astroid, and nothing opinionated is allowed into Louise's
28
+ exports. This keeps the toolkit neutral while Astroid holds the opinions.
29
+
30
+ ## Configure
31
+
32
+ The whole shape of a project — its brand + theme + editable home, its commerce
33
+ backend and optional modules — collapses into one typed config. **One brand per
34
+ project:** every site Astroid targets serves a single brand from a single deploy,
35
+ so the config describes one brand, not an array. What actually multiplexes is
36
+ *editors* (Louise's org plugin) and *audiences* (a gated portal beside the public
37
+ site) — both options on the one brand. The vocabulary is drawn from the real
38
+ sites Astroid targets: a storefront (coracle.coffee), a wholesale front
39
+ (ghostfire.coffee), an artist portfolio (themidwestartist.com), and a plain
40
+ marketing baseline (louise-web).
41
+
42
+ ```ts
43
+ import { defineAstroid } from "astroidjs";
44
+
45
+ export default defineAstroid({
46
+ key: "coracle",
47
+ archetype: "storefront",
48
+ theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
49
+ sections: ["hero", "marquee", "featured", "productGrid", "visit"],
50
+ commerce: { provider: "square" },
51
+ deploy: { platform: "cloudflare" },
52
+ });
53
+ ```
54
+
55
+ A portfolio with a gated client area, for contrast:
56
+
57
+ ```ts
58
+ export default defineAstroid({
59
+ key: "megbowen",
60
+ archetype: "portfolio",
61
+ theme: { name: "Meg Bowen Studio", colors: { brand: "#2b2b2b" } },
62
+ sections: ["hero", "gallery", "story", "contact"],
63
+ portal: { enabled: true, gated: true },
64
+ deploy: { platform: "cloudflare" },
65
+ });
66
+ ```
67
+
68
+ ## CLI
69
+
70
+ The `astroid` command turns the config into the Louise wiring and keeps it in
71
+ sync. It loads your `astroid.config.ts` with Node's native TypeScript stripping,
72
+ so there is no separate config-compile step.
73
+
74
+ ```
75
+ astroid generate regenerate src/schema.ts, src/worker.ts, src/middleware.ts from the config
76
+ astroid doctor validate the config, the wrangler bindings, and generated-file freshness
77
+ astroid dev regenerate, then run `astro dev`
78
+ astroid build regenerate, then run `astro build`
79
+ astroid deploy provision bindings + migrate + secrets + deploy (--dry-run / --yes)
80
+ ```
81
+
82
+ `deploy` is plan-first: it prints exactly what it will run and refuses to
83
+ provision non-interactively without `--yes` (use `--dry-run` to preview).
84
+
85
+ The generated trio carries a "do not hand-edit" banner — `generate` (and
86
+ `dev`/`build`) rewrite them on every run, and `doctor` diffs them against your
87
+ config to catch drift. Your `wrangler.jsonc` is scaffolded once and then yours to
88
+ edit (real binding ids, secrets); `generate` never touches it.
89
+
90
+ New projects come from the `create-astroid` scaffold (`npm create astroid`), which
91
+ writes the floor — config, the generated trio, `wrangler.jsonc`, and the baseline
92
+ Astro app — in one step.
93
+
94
+ ## Roadmap
95
+
96
+ 1. ✅ **Config surface** (`defineAstroid`) — single brand per project.
97
+ 2. ✅ Config → generated Drizzle schema.
98
+ 3. ✅ Config → generated `worker.ts` + middleware (no hand-wired route ordering).
99
+ 4. ✅ `<Section>` / `<Editable>` / `<Collection>` component primitives.
100
+ 5. ✅ **CLI** — `astroid generate / doctor / dev / build / deploy`; `create-astroid`
101
+ scaffold (`npm create astroid`).
102
+
103
+ ## License
104
+
105
+ [MIT](https://github.com/bowenlabs/louise-toolkit/blob/main/LICENSE) © BowenLabs
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
3
+ //
4
+ // The `astroid` CLI — the meta-framework's project commands:
5
+ //
6
+ // astroid generate [--config <path>] [--cwd <dir>] regenerate schema/worker/middleware from the config
7
+ // astroid doctor [--config <path>] [--cwd <dir>] validate config + bindings + generated-file freshness
8
+ // astroid dev [...astro args] generate, then `astro dev`
9
+ // astroid build [...astro args] generate, then `astro build`
10
+ // astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
11
+ //
12
+ // It loads the project's `astroid.config.ts` with Node's native TypeScript
13
+ // stripping (the config only imports the built `astroidjs`, so it resolves), and
14
+ // consumes this package's own built generators from ../dist — the same version the
15
+ // CLI ships in, no dependency on node_modules layout (mirrors the louise bin).
16
+
17
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { dirname, isAbsolute, join, resolve } from "node:path";
21
+ import { createInterface } from "node:readline/promises";
22
+ import { pathToFileURL } from "node:url";
23
+
24
+ const GENERATORS_URL = new URL("../dist/index.js", import.meta.url).href;
25
+
26
+ // --- tiny arg parser -------------------------------------------------------
27
+ // Splits at the first non-flag token into { command, flags, rest }. `rest` is
28
+ // everything after the command, preserved verbatim so `dev`/`build` can forward
29
+ // arbitrary astro flags.
30
+ function parseArgs(argv) {
31
+ const [command, ...tail] = argv;
32
+ const flags = {};
33
+ const rest = [];
34
+ for (let i = 0; i < tail.length; i++) {
35
+ const a = tail[i];
36
+ if (a === "--config" || a === "-c") flags.config = tail[++i];
37
+ else if (a === "--cwd") flags.cwd = tail[++i];
38
+ else rest.push(a);
39
+ }
40
+ return { command, flags, rest };
41
+ }
42
+
43
+ // --- config loading --------------------------------------------------------
44
+ const CONFIG_CANDIDATES = ["astroid.config.ts", "astroid.config.mjs", "astroid.config.js"];
45
+
46
+ function resolveConfigPath(cwd, explicit) {
47
+ if (explicit) {
48
+ const abs = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
49
+ if (!existsSync(abs)) fail(`Config not found: ${explicit}`);
50
+ return abs;
51
+ }
52
+ for (const name of CONFIG_CANDIDATES) {
53
+ const abs = join(cwd, name);
54
+ if (existsSync(abs)) return abs;
55
+ }
56
+ fail(
57
+ `No Astroid config found in ${cwd}.\n` +
58
+ `Expected one of: ${CONFIG_CANDIDATES.join(", ")} (or pass --config <path>).`,
59
+ );
60
+ }
61
+
62
+ async function loadConfig(cwd, explicit) {
63
+ const path = resolveConfigPath(cwd, explicit);
64
+ let mod;
65
+ try {
66
+ mod = await import(pathToFileURL(path).href);
67
+ } catch (err) {
68
+ // A `defineAstroid` invariant violation (bad key/theme) throws here at import.
69
+ fail(`Failed to load ${path}:\n${err instanceof Error ? err.message : String(err)}`);
70
+ }
71
+ const config = mod.default ?? mod.config;
72
+ if (!config || typeof config !== "object") {
73
+ fail(`${path} must \`export default defineAstroid({ … })\`.`);
74
+ }
75
+ return { config, path };
76
+ }
77
+
78
+ // --- commands --------------------------------------------------------------
79
+ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
80
+ const { generateAstroidProject } = await import(GENERATORS_URL);
81
+ const { config } = await loadConfig(cwd, flags.config);
82
+ const files = generateAstroidProject(config);
83
+ for (const file of files) {
84
+ const abs = join(cwd, file.path);
85
+ mkdirSync(dirname(abs), { recursive: true });
86
+ writeFileSync(abs, file.contents);
87
+ if (!quiet) out(` ✓ ${file.path}`);
88
+ }
89
+ if (!quiet) out(`Generated ${files.length} file(s) from your defineAstroid config.`);
90
+ return files;
91
+ }
92
+
93
+ async function cmdDoctor(cwd, flags) {
94
+ const { generateAstroidProject } = await import(GENERATORS_URL);
95
+ const { config, path: configPath } = await loadConfig(cwd, flags.config);
96
+
97
+ const problems = []; // { level: "error" | "warn", msg }
98
+ const err = (msg) => problems.push({ level: "error", msg });
99
+ const warn = (msg) => problems.push({ level: "warn", msg });
100
+ const oks = [];
101
+ const ok = (msg) => oks.push(msg);
102
+
103
+ ok(`config loads and validates (${rel(cwd, configPath)})`);
104
+
105
+ // 1. Generated trio freshness — regenerate in memory, diff against disk.
106
+ for (const file of generateAstroidProject(config)) {
107
+ const abs = join(cwd, file.path);
108
+ if (!existsSync(abs)) {
109
+ err(`${file.path} is missing — run \`astroid generate\`.`);
110
+ } else if (readFileSync(abs, "utf8") !== file.contents) {
111
+ warn(`${file.path} is stale (out of sync with your config) — run \`astroid generate\`.`);
112
+ } else {
113
+ ok(`${file.path} is up to date`);
114
+ }
115
+ }
116
+
117
+ // 2. wrangler.jsonc bindings — presence checks + placeholder detection. Read as
118
+ // text (JSONC with comments/trailing commas) rather than parse, to stay robust.
119
+ const wranglerPath = join(cwd, "wrangler.jsonc");
120
+ if (!existsSync(wranglerPath)) {
121
+ err("wrangler.jsonc is missing — scaffold with `create-astroid`.");
122
+ } else {
123
+ const w = readFileSync(wranglerPath, "utf8");
124
+ const hasBinding = (name) => new RegExp(`"binding"\\s*:\\s*"${name}"`).test(w);
125
+ if (hasBinding("DB")) ok("wrangler: D1 `DB` binding present");
126
+ else err("wrangler.jsonc has no D1 `DB` binding.");
127
+ if (hasBinding("MEDIA")) ok("wrangler: R2 `MEDIA` binding present");
128
+ else err("wrangler.jsonc has no R2 `MEDIA` binding.");
129
+ if (/"main"\s*:\s*"src\/worker\.ts"/.test(w)) ok("wrangler: `main` → src/worker.ts");
130
+ else warn("wrangler.jsonc `main` does not point at src/worker.ts.");
131
+ const placeholders = w.match(/<run:[^>]*>|<your-[^>]*>/g);
132
+ if (placeholders) {
133
+ warn(
134
+ `wrangler.jsonc has ${placeholders.length} unresolved placeholder(s) ` +
135
+ `(create the bindings, e.g. \`wrangler d1 create\`, then fill the ids).`,
136
+ );
137
+ }
138
+ }
139
+
140
+ // 3. migrations directory (matches the generated wrangler `migrations_dir`).
141
+ if (existsSync(join(cwd, "migrations"))) ok("migrations/ directory present");
142
+ else warn("no migrations/ directory — create your D1 schema migrations there.");
143
+
144
+ // --- report ---
145
+ for (const m of oks) out(` ✓ ${m}`);
146
+ for (const p of problems) {
147
+ if (p.level === "warn") out(` ! ${p.msg}`);
148
+ else out(` ✗ ${p.msg}`);
149
+ }
150
+ const errors = problems.filter((p) => p.level === "error").length;
151
+ const warns = problems.filter((p) => p.level === "warn").length;
152
+ out("");
153
+ if (errors) {
154
+ out(`doctor: ${errors} error(s), ${warns} warning(s).`);
155
+ process.exit(1);
156
+ }
157
+ out(warns ? `doctor: healthy, ${warns} warning(s).` : "doctor: all checks passed.");
158
+ }
159
+
160
+ async function cmdAstro(cwd, subcommand, flags, rest) {
161
+ // Regenerate first so schema/worker/middleware always match the config, then
162
+ // hand off to the project's own astro. `dev`/`build` are thin wrappers.
163
+ out(`astroid: regenerating from config…`);
164
+ await cmdGenerate(cwd, flags, { quiet: true });
165
+ const astroBin = resolveBin(cwd, "astro", "astro");
166
+ if (!astroBin) {
167
+ fail("Could not find `astro` in this project. Run inside an Astroid project (with astro installed).");
168
+ }
169
+ const child = spawn(process.execPath, [astroBin, subcommand, ...rest], { stdio: "inherit", cwd });
170
+ child.on("exit", (code) => process.exit(code ?? 0));
171
+ }
172
+
173
+ /** Resolve a project-local CLI bin (astro, wrangler) to an absolute path via the
174
+ * project's own dependency resolution — so we run the version it ships. */
175
+ function resolveBin(cwd, pkgName, binName) {
176
+ try {
177
+ const require = createRequire(join(cwd, "package.json"));
178
+ const pkgPath = require.resolve(`${pkgName}/package.json`);
179
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
180
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[binName];
181
+ return binRel ? join(dirname(pkgPath), binRel) : null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ // --- deploy ----------------------------------------------------------------
188
+ // `astroid deploy` orchestrates the one-time platform bring-up: provision the
189
+ // bindings that still hold placeholder ids (D1/R2/KV), apply migrations, prompt
190
+ // for secrets, and deploy — all by shelling out to the project's own `wrangler`.
191
+ // It's plan-first: it prints exactly what it will run, and only proceeds past the
192
+ // irreversible steps on an interactive `y` (or `--yes`). `--dry-run` prints the
193
+ // plan and stops; `--local` targets the local D1 for migrations.
194
+
195
+ const isPlaceholder = (v) => !v || /^<.*>$/.test(v);
196
+
197
+ /** Pull the deploy-relevant bits out of wrangler.jsonc by regex — robust against
198
+ * its JSONC comments + trailing commas (a strict JSON.parse would throw). */
199
+ function readWranglerFacts(text) {
200
+ return {
201
+ name: text.match(/"name":\s*"([^"]+)"/)?.[1],
202
+ d1Name: text.match(/"database_name":\s*"([^"]+)"/)?.[1],
203
+ d1Id: text.match(/"database_id":\s*"([^"]*)"/)?.[1],
204
+ r2: [...text.matchAll(/"bucket_name":\s*"([^"]+)"/g)].map((m) => m[1]),
205
+ kv: [...text.matchAll(/{\s*"binding":\s*"([^"]+)",\s*"id":\s*"([^"]*)"/g)].map((m) => ({
206
+ binding: m[1],
207
+ id: m[2],
208
+ })),
209
+ // Real (uncommented) account_id line, filled in?
210
+ hasAccount: /^\s*"account_id":\s*"[^<][^"]*"/m.test(text),
211
+ };
212
+ }
213
+
214
+ /** Build the ordered provisioning plan from the still-placeholder bindings. */
215
+ function provisionPlan(facts) {
216
+ const steps = [];
217
+ for (const bucket of facts.r2) {
218
+ steps.push({ kind: "r2", name: bucket, args: ["r2", "bucket", "create", bucket] });
219
+ }
220
+ if (facts.d1Name && isPlaceholder(facts.d1Id)) {
221
+ steps.push({ kind: "d1", name: facts.d1Name, args: ["d1", "create", facts.d1Name] });
222
+ }
223
+ for (const { binding, id } of facts.kv) {
224
+ if (isPlaceholder(id)) {
225
+ steps.push({ kind: "kv", name: binding, args: ["kv", "namespace", "create", binding] });
226
+ }
227
+ }
228
+ return steps;
229
+ }
230
+
231
+ /** Look up a just-created resource's id by name via a `… list` JSON command. */
232
+ function lookupId(wranglerBin, cwd, args, pick) {
233
+ try {
234
+ const rows = JSON.parse(execFileSync(process.execPath, [wranglerBin, ...args], { cwd, encoding: "utf8" }));
235
+ return pick(Array.isArray(rows) ? rows : []) ?? null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /** Replace a KV binding's placeholder id in the wrangler.jsonc text. */
242
+ function patchKvId(text, binding, id) {
243
+ return text.replace(
244
+ new RegExp(`("binding":\\s*"${binding}",\\s*"id":\\s*)"<[^"]*>"`),
245
+ `$1${JSON.stringify(id)}`,
246
+ );
247
+ }
248
+
249
+ async function cmdDeploy(cwd, flags, rest) {
250
+ const dryRun = rest.includes("--dry-run");
251
+ const assumeYes = rest.includes("--yes") || rest.includes("-y");
252
+ const remoteArgs = rest.includes("--local") ? [] : ["--remote"];
253
+
254
+ await loadConfig(cwd, flags.config); // validates the config (throws on a bad shape)
255
+ const wranglerPath = join(cwd, "wrangler.jsonc");
256
+ if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
257
+
258
+ // Regenerate so the shipped worker/schema always match the config.
259
+ await cmdGenerate(cwd, flags, { quiet: true });
260
+
261
+ let wrangler = readFileSync(wranglerPath, "utf8");
262
+ const facts = readWranglerFacts(wrangler);
263
+ const plan = provisionPlan(facts);
264
+
265
+ // Print the plan.
266
+ out("astroid deploy — plan:\n");
267
+ out(" Provision:");
268
+ if (plan.length === 0) out(" (all bindings already have ids)");
269
+ for (const s of plan) out(` wrangler ${s.args.join(" ")}`);
270
+ out(`\n Migrate: wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
271
+ out(" Secrets: wrangler secret put SESSION_SECRET (prompted)");
272
+ out(" Deploy: wrangler deploy\n");
273
+ if (!facts.hasAccount) {
274
+ out(" ! No account_id set — uncomment it in wrangler.jsonc or export CLOUDFLARE_ACCOUNT_ID.\n");
275
+ }
276
+
277
+ if (dryRun) {
278
+ out("(dry run — nothing executed)");
279
+ return;
280
+ }
281
+
282
+ // Gate the irreversible work behind a clear yes.
283
+ if (!assumeYes) {
284
+ if (!process.stdin.isTTY) {
285
+ fail("Refusing to provision + deploy non-interactively. Re-run with --yes (or --dry-run to preview).");
286
+ }
287
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
288
+ const answer = (await rl.question("Provision the above, then migrate + deploy? [y/N] ")).trim().toLowerCase();
289
+ rl.close();
290
+ if (answer !== "y" && answer !== "yes") {
291
+ out("Aborted.");
292
+ return;
293
+ }
294
+ }
295
+
296
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
297
+ if (!wranglerBin) fail("Could not find `wrangler` in this project (add it as a devDependency).");
298
+ const runInherit = (args) => spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
299
+
300
+ // 1) Provision, patching discovered ids back into wrangler.jsonc.
301
+ for (const s of plan) {
302
+ out(`\n▸ wrangler ${s.args.join(" ")}`);
303
+ 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(" ")}`);
306
+
307
+ if (s.kind === "d1") {
308
+ const id = lookupId(wranglerBin, cwd, ["d1", "list", "--json"], (rows) => rows.find((r) => r.name === s.name)?.uuid);
309
+ if (id) {
310
+ wrangler = wrangler.replace(/"database_id":\s*"<[^"]*>"/, `"database_id": ${JSON.stringify(id)}`);
311
+ writeFileSync(wranglerPath, wrangler);
312
+ out(` ↳ database_id = ${id}`);
313
+ } else {
314
+ out(" ↳ couldn't auto-detect the id — fill database_id in wrangler.jsonc by hand.");
315
+ }
316
+ } else if (s.kind === "kv") {
317
+ const id = lookupId(wranglerBin, cwd, ["kv", "namespace", "list"], (rows) =>
318
+ rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
319
+ );
320
+ if (id) {
321
+ wrangler = patchKvId(wrangler, s.name, id);
322
+ writeFileSync(wranglerPath, wrangler);
323
+ out(` ↳ ${s.name} id = ${id}`);
324
+ } else {
325
+ out(` ↳ couldn't auto-detect ${s.name}'s id — fill it in wrangler.jsonc by hand.`);
326
+ }
327
+ }
328
+ }
329
+
330
+ // 2) Migrations.
331
+ out(`\n▸ wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
332
+ if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0) fail("Migrations failed.");
333
+
334
+ // 3) Secrets (interactive; wrangler prompts for the value).
335
+ out("\n▸ wrangler secret put SESSION_SECRET");
336
+ runInherit(["secret", "put", "SESSION_SECRET"]);
337
+
338
+ // 4) Deploy.
339
+ out("\n▸ wrangler deploy");
340
+ if (runInherit(["deploy"]).status !== 0) fail("Deploy failed.");
341
+ out("\n✓ Deployed.");
342
+ }
343
+
344
+ // --- helpers ---------------------------------------------------------------
345
+ function out(s) {
346
+ process.stdout.write(`${s}\n`);
347
+ }
348
+ function fail(msg) {
349
+ process.stderr.write(`astroid: ${msg}\n`);
350
+ process.exit(1);
351
+ }
352
+ function rel(cwd, abs) {
353
+ return abs.startsWith(cwd) ? abs.slice(cwd.length + 1) : abs;
354
+ }
355
+
356
+ const USAGE = `astroid — the Astroid meta-framework CLI
357
+
358
+ Usage:
359
+ astroid generate [--config <path>] [--cwd <dir>] regenerate src/schema.ts, src/worker.ts, src/middleware.ts
360
+ astroid doctor [--config <path>] [--cwd <dir>] validate config, bindings, and generated-file freshness
361
+ astroid dev [...astro args] regenerate, then run \`astro dev\`
362
+ astroid build [...astro args] regenerate, then run \`astro build\`
363
+ astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
364
+
365
+ New project: npm create astroid@latest
366
+ `;
367
+
368
+ async function main() {
369
+ const { command, flags, rest } = parseArgs(process.argv.slice(2));
370
+ const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
371
+
372
+ switch (command) {
373
+ case "generate":
374
+ case "gen":
375
+ await cmdGenerate(cwd, flags);
376
+ break;
377
+ case "doctor":
378
+ await cmdDoctor(cwd, flags);
379
+ break;
380
+ case "dev":
381
+ await cmdAstro(cwd, "dev", flags, rest);
382
+ break;
383
+ case "build":
384
+ await cmdAstro(cwd, "build", flags, rest);
385
+ break;
386
+ case "deploy":
387
+ await cmdDeploy(cwd, flags, rest);
388
+ break;
389
+ case "help":
390
+ case "--help":
391
+ case "-h":
392
+ case undefined:
393
+ out(USAGE);
394
+ process.exit(command ? 0 : 1);
395
+ break;
396
+ default:
397
+ process.stderr.write(`astroid: unknown command "${command}"\n\n${USAGE}`);
398
+ process.exit(1);
399
+ }
400
+ }
401
+
402
+ main().catch((err) => {
403
+ process.stderr.write(`${err instanceof Error ? err.stack : String(err)}\n`);
404
+ process.exit(1);
405
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The starting shape the front-end takes. Not a fork — each archetype is a preset
3
+ * of defaults (which sections/modules are on, nav shape) that the site then tunes.
4
+ * `marketing` = the lean brochure floor (louise-web, no commerce); `storefront` =
5
+ * DTC shop (coracle); `wholesale` = B2B/private-label (ghostfire); `portfolio` =
6
+ * gallery + prints + client portal (megbowen).
7
+ */
8
+ export type Archetype = "marketing" | "storefront" | "wholesale" | "portfolio";
9
+ /**
10
+ * The section vocabulary — the editable home page is an ordered list of these, top
11
+ * to bottom. Each maps to a themeable component in the Astroid section library.
12
+ * Drawn from real usage across the target sites (annotated below).
13
+ */
14
+ export type SectionKind = "hero" | "marquee" | "featureGrid" | "featured" | "productGrid" | "gallery" | "story" | "visit" | "cta" | "testimonial" | "contact";
15
+ /**
16
+ * Optional capabilities the site switches on. Pluggable, not core — a portfolio
17
+ * site runs none of the commerce ones. `orderTracking` is shared across both
18
+ * coffee brands, so it's first-class but still opt-in.
19
+ */
20
+ export type ModuleKind = "orderTracking" | "subscriptions" | "giftCards" | "wholesaleInquiry" | "privateLabel";
21
+ /** Commerce backend — mirrors Louise's provider set (louise-toolkit/commerce). */
22
+ export type CommerceProvider = "stripe" | "square" | "fourthwall";
23
+ export interface Theme {
24
+ /** Display name — the brand, used in nav, `<title>`, OG cards. */
25
+ name: string;
26
+ /** Path to the primary logo (media-library asset or a `/brand/*` file). */
27
+ logo?: string;
28
+ /**
29
+ * Brand color tokens → CSS variables + a daisyUI theme, surfaced in Louise
30
+ * Settings so the brand is editable in place (not hard-coded). `brand` is
31
+ * required; `secondary`/`tertiary` mirror `site_settings`' existing columns.
32
+ */
33
+ colors: {
34
+ brand: string;
35
+ secondary?: string;
36
+ tertiary?: string;
37
+ };
38
+ /** Font preset key (a bundled `@font-face` set) or a custom family name. */
39
+ font?: string;
40
+ }
41
+ export interface Portal {
42
+ enabled: boolean;
43
+ /** Require a session to view the whole site (Meg Bowen's gated preview), not
44
+ * just the account area. Default `false`. */
45
+ gated?: boolean;
46
+ /** Modules exposed inside the account area (e.g. `orderTracking`). */
47
+ features?: ModuleKind[];
48
+ }
49
+ export interface CommerceConfig {
50
+ provider: CommerceProvider;
51
+ }
52
+ export interface DeployConfig {
53
+ platform: "cloudflare";
54
+ /** Media base for R2 + `cf-image` resizing — matches Louise's media route
55
+ * (`media.<brand>/cdn-cgi/image`). Default `"/media"`. */
56
+ mediaBase?: string;
57
+ }
58
+ export interface AstroidConfig {
59
+ /**
60
+ * Stable project slug — the worker/D1/R2 base name and default subdomain (e.g.
61
+ * `"coracle"`). Required and non-empty; it drives the generated binding names.
62
+ */
63
+ key: string;
64
+ /** Hostname(s) this site serves (prod + preview), for custom-domain routes. */
65
+ hosts?: string[];
66
+ /** Starting shape; sets section/module/nav defaults the site can override. */
67
+ archetype: Archetype;
68
+ /** The single brand's theme (display name + color tokens + font). */
69
+ theme: Theme;
70
+ /** The editable home page, top to bottom. Omit to take the archetype default. */
71
+ sections?: SectionKind[];
72
+ /** Optional capabilities switched on for this site. */
73
+ modules?: ModuleKind[];
74
+ /** Gated account/portal area (order tracking, client galleries). */
75
+ portal?: Portal;
76
+ /** Commerce backend. */
77
+ commerce?: CommerceConfig;
78
+ deploy?: DeployConfig;
79
+ }
80
+ /**
81
+ * Define an Astroid project. An identity function in the shape of Astro's
82
+ * `defineConfig`: it returns the config verbatim with full type-checking +
83
+ * inference, and validates the invariants that would otherwise fail deep inside
84
+ * generation (a non-empty project `key`, since it names the generated bindings;
85
+ * a brand `theme.name` + `colors.brand`, since they seed the site and theme).
86
+ *
87
+ * ```ts
88
+ * export default defineAstroid({
89
+ * key: "coracle",
90
+ * archetype: "storefront",
91
+ * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
92
+ * sections: ["hero", "marquee", "featured", "productGrid", "visit"],
93
+ * commerce: { provider: "square" },
94
+ * deploy: { platform: "cloudflare" },
95
+ * });
96
+ * ```
97
+ */
98
+ export declare function defineAstroid(config: AstroidConfig): AstroidConfig;
package/dist/config.js ADDED
@@ -0,0 +1,52 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // `defineAstroid` — the Astroid project configuration surface.
4
+ //
5
+ // Astroid is the opinionated layer over Louise Toolkit + Astro. A site's whole
6
+ // shape — its brand + theme + editable home, its commerce backend, its optional
7
+ // modules — collapses into ONE typed config here. Astroid consumes it to generate
8
+ // the Louise wiring (worker routes, middleware, Drizzle schema, theme tokens) a
9
+ // site would otherwise hand-write per repo.
10
+ //
11
+ // ONE brand per project. Every site Astroid targets (coracle.coffee,
12
+ // ghostfire.coffee, themidwestartist.com, louise-web) serves a single brand from a
13
+ // single deploy — none does host/tenant dispatch. The axis that genuinely
14
+ // multiplexes is *editors* (Louise's org plugin, #100) and *audiences* (a gated
15
+ // portal alongside the public site), not brands — so both live here as options on
16
+ // the one brand, not as a `brands[]` array.
17
+ //
18
+ // The vocabulary below is not invented: `Archetype`, `SectionKind`, and
19
+ // `ModuleKind` are extracted from the real sites Astroid targets — a storefront
20
+ // (coracle), a wholesale front (ghostfire), an artist portfolio (megbowen), and a
21
+ // plain marketing baseline (louise-web).
22
+ import { AstroidConfigError } from "./errors.js";
23
+ /**
24
+ * Define an Astroid project. An identity function in the shape of Astro's
25
+ * `defineConfig`: it returns the config verbatim with full type-checking +
26
+ * inference, and validates the invariants that would otherwise fail deep inside
27
+ * generation (a non-empty project `key`, since it names the generated bindings;
28
+ * a brand `theme.name` + `colors.brand`, since they seed the site and theme).
29
+ *
30
+ * ```ts
31
+ * export default defineAstroid({
32
+ * key: "coracle",
33
+ * archetype: "storefront",
34
+ * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
35
+ * sections: ["hero", "marquee", "featured", "productGrid", "visit"],
36
+ * commerce: { provider: "square" },
37
+ * deploy: { platform: "cloudflare" },
38
+ * });
39
+ * ```
40
+ */
41
+ export function defineAstroid(config) {
42
+ if (!config.key || config.key.trim().length === 0) {
43
+ throw new AstroidConfigError("Astroid config requires a non-empty `key` (it names the generated worker/D1/R2 bindings)");
44
+ }
45
+ if (!config.theme || !config.theme.name || config.theme.name.trim().length === 0) {
46
+ throw new AstroidConfigError("Astroid config requires `theme.name` (the brand's display name)");
47
+ }
48
+ if (!config.theme.colors || !config.theme.colors.brand) {
49
+ throw new AstroidConfigError("Astroid config requires `theme.colors.brand` (the primary brand color)");
50
+ }
51
+ return config;
52
+ }