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 +21 -0
- package/README.md +105 -0
- package/bin/astroid.mjs +405 -0
- package/dist/config.d.ts +98 -0
- package/dist/config.js +52 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +10 -0
- package/dist/project/generate.d.ts +26 -0
- package/dist/project/generate.js +110 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +5 -0
- package/dist/schema/collections.d.ts +20 -0
- package/dist/schema/collections.js +64 -0
- package/dist/schema/framework.d.ts +13 -0
- package/dist/schema/framework.js +27 -0
- package/dist/schema/generate.d.ts +8 -0
- package/dist/schema/generate.js +48 -0
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +6 -0
- package/dist/worker/generate.d.ts +24 -0
- package/dist/worker/generate.js +200 -0
- package/dist/worker/index.d.ts +2 -0
- package/dist/worker/index.js +6 -0
- package/dist/worker/routes.d.ts +17 -0
- package/dist/worker/routes.js +70 -0
- package/package.json +71 -0
- package/src/components/Collection.tsx +43 -0
- package/src/components/Editable.astro +71 -0
- package/src/components/Section.astro +46 -0
- package/src/components/sections/Contact.astro +18 -0
- package/src/components/sections/Cta.astro +17 -0
- package/src/components/sections/FeatureGrid.astro +26 -0
- package/src/components/sections/Hero.astro +23 -0
- package/src/components/sections.ts +108 -0
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
|
package/bin/astroid.mjs
ADDED
|
@@ -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
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|