astroidjs 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -339,11 +339,13 @@ Astro app—in one step.
339
339
 
340
340
  ## Roadmap
341
341
 
342
- 1. ✅ **Config surface** (`defineAstroid`)—single brand per project.
343
- 2. ✅ Config → generated Drizzle schema.
344
- 3. ✅ Config → generated `worker.ts` + middleware (no hand-wired route ordering).
345
- 4. ✅ `<Section>` / `<Editable>` / `<Collection>` component primitives.
346
- 5. ✅ **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
342
+ Every item on the first roadmap has shipped:
343
+
344
+ 1. **Config surface** (`defineAstroid`)—single brand per project.
345
+ 2. Config → generated Drizzle schema.
346
+ 3. Config → generated `worker.ts` + middleware (no hand-wired route ordering).
347
+ 4. `<Section>` / `<Editable>` / `<Collection>` component primitives.
348
+ 5. **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
347
349
  scaffold (`pnpm create astroid`).
348
350
 
349
351
  ## License
package/bin/astroid.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // astroid dev [...astro args] generate, then `astro dev`
9
9
  // astroid build [...astro args] generate, then `astro build`
10
10
  // astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
11
+ // astroid ship production | preview migrate, then deploy or preview (Workers Builds)
11
12
  //
12
13
  // It loads the project's `astroid.config.ts` with Node's native TypeScript
13
14
  // stripping (the config only imports the built `astroidjs`, so it resolves), and
@@ -15,6 +16,7 @@
15
16
  // CLI ships in, no dependency on node_modules layout (mirrors the louise bin).
16
17
 
17
18
  import { execFileSync, spawn, spawnSync } from "node:child_process";
19
+ import { randomBytes } from "node:crypto";
18
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
21
  import { createRequire } from "node:module";
20
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -23,6 +25,16 @@ import { pathToFileURL } from "node:url";
23
25
 
24
26
  const GENERATORS_URL = new URL("../dist/index.js", import.meta.url).href;
25
27
 
28
+ /** The repository root, where `.github/` lives, or null outside a git checkout.
29
+ * A site's Astroid project can sit below it (`workers/site`). */
30
+ function gitRoot(cwd) {
31
+ try {
32
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
26
38
  // --- tiny arg parser -------------------------------------------------------
27
39
  // Splits at the first non-flag token into { command, flags, rest }. `rest` is
28
40
  // everything after the command, preserved verbatim so `dev`/`build` can forward
@@ -77,7 +89,12 @@ async function loadConfig(cwd, explicit) {
77
89
 
78
90
  // --- commands --------------------------------------------------------------
79
91
  async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
80
- const { generateAstroidProject, generateAstroidScaffoldFiles } = await import(GENERATORS_URL);
92
+ const {
93
+ generateAstroidProject,
94
+ generateAstroidScaffoldFiles,
95
+ generateAstroidReleaseWorkflow,
96
+ ASTROID_RELEASE_WORKFLOW_PATH,
97
+ } = await import(GENERATORS_URL);
81
98
  const { config } = await loadConfig(cwd, flags.config);
82
99
  const files = generateAstroidProject(config);
83
100
  for (const file of files) {
@@ -87,6 +104,16 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
87
104
  if (!quiet) out(` ✓ ${file.path}`);
88
105
  }
89
106
 
107
+ // The release workflow, at the repository root rather than the project,
108
+ // because GitHub reads workflows only from there. Regenerated like the trio.
109
+ const root = gitRoot(cwd);
110
+ if (root) {
111
+ const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
112
+ mkdirSync(dirname(abs), { recursive: true });
113
+ writeFileSync(abs, generateAstroidReleaseWorkflow());
114
+ if (!quiet) out(` ✓ ${ASTROID_RELEASE_WORKFLOW_PATH} (repository root)`);
115
+ }
116
+
90
117
  // Scaffold-once files for whatever modules the config switched on.
91
118
  //
92
119
  // Written only when ABSENT—each is a seam the project owns, so overwriting
@@ -124,8 +151,15 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
124
151
  }
125
152
 
126
153
  async function cmdDoctor(cwd, flags) {
127
- const { generateAstroidProject, generateAstroidScaffoldFiles, astroidUsesQueues, astroidCrons } =
128
- await import(GENERATORS_URL);
154
+ const {
155
+ generateAstroidProject,
156
+ generateAstroidScaffoldFiles,
157
+ astroidUsesQueues,
158
+ astroidCrons,
159
+ checkWranglerPreviews,
160
+ generateAstroidReleaseWorkflow,
161
+ ASTROID_RELEASE_WORKFLOW_PATH,
162
+ } = await import(GENERATORS_URL);
129
163
  const { config, path: configPath } = await loadConfig(cwd, flags.config);
130
164
 
131
165
  const problems = []; // { level: "error" | "warn", msg }
@@ -264,6 +298,31 @@ async function cmdDoctor(cwd, flags) {
264
298
  }
265
299
  }
266
300
 
301
+ // 1c. The release workflow at the repository root: a tag on `main` moves
302
+ // `deploy/production`, which Workers Builds deploys. Stale is an error for
303
+ // the same reason the trio's is: it decides which commits reach production.
304
+ const root = gitRoot(cwd);
305
+ if (!root) {
306
+ warn(`not in a git checkout, so ${ASTROID_RELEASE_WORKFLOW_PATH} can't be checked.`);
307
+ } else {
308
+ const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
309
+ if (!existsSync(abs))
310
+ err(`${ASTROID_RELEASE_WORKFLOW_PATH} is missing — run \`astroid generate\`.`);
311
+ else if (readFileSync(abs, "utf8") !== generateAstroidReleaseWorkflow())
312
+ err(`${ASTROID_RELEASE_WORKFLOW_PATH} is stale — run \`astroid generate\`.`);
313
+ else ok(`${ASTROID_RELEASE_WORKFLOW_PATH} is up to date`);
314
+ }
315
+
316
+ // 2b. Staging: the `previews` block (louise-toolkit ADR 0017). A Preview
317
+ // inherits nothing, so a binding left out crashes it and one copied from
318
+ // production writes production data; see src/project/previews.ts.
319
+ if (existsSync(wranglerPath)) {
320
+ const previews = checkWranglerPreviews(readFileSync(wranglerPath, "utf8"));
321
+ for (const m of previews.ok) ok(m);
322
+ for (const m of previews.warnings) warn(m);
323
+ for (const m of previews.errors) err(m);
324
+ }
325
+
267
326
  // 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
268
327
  // or `migrations/`, the generated default, when it names none. A site that
269
328
  // keeps its migrations under another name (drizzle/) isn't missing them.
@@ -403,7 +462,10 @@ function provisionPlan(facts) {
403
462
  }
404
463
  for (const { binding, id } of facts.kv) {
405
464
  if (isPlaceholder(id)) {
406
- steps.push({ kind: "kv", name: binding, args: ["kv", "namespace", "create", binding] });
465
+ // The placeholder names the namespace (`<run: wrangler kv namespace
466
+ // create acme-rl>`); an older one without a name falls back to the binding.
467
+ const title = id.match(/kv namespace create ([^\s>]+)/)?.[1] ?? binding;
468
+ steps.push({ kind: "kv", name: title, binding, args: ["kv", "namespace", "create", title] });
407
469
  }
408
470
  }
409
471
  // Queues carry no id, so there's no placeholder to test—creating one that
@@ -531,7 +593,7 @@ async function cmdDeploy(cwd, flags, rest) {
531
593
  (rows) => rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
532
594
  );
533
595
  if (id) {
534
- wrangler = patchKvId(wrangler, s.name, id);
596
+ wrangler = patchKvId(wrangler, s.binding, id);
535
597
  writeFileSync(wranglerPath, wrangler);
536
598
  out(` ↳ ${s.name} id = ${id}`);
537
599
  } else {
@@ -597,10 +659,294 @@ Usage:
597
659
  astroid dev [...astro args] regenerate, then run \`astro dev\`
598
660
  astroid build [...astro args] regenerate, then run \`astro build\`
599
661
  astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
662
+ astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
663
+ astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
600
664
 
601
665
  New project: pnpm create astroid@latest
602
666
  `;
603
667
 
668
+ // `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
669
+ // top level and `previews` alike, and writes each new ID back in place of its
670
+ // placeholder. It never deploys, so it's safe to run before a site's first
671
+ // release, and re-running it only creates what's still missing. It creates the
672
+ // staging secrets that need no person (a random SESSION_SECRET and Turnstile's
673
+ // test secret), skipping any the store already has. Every other secret, and
674
+ // dashboard settings, need a person, so it prints those instead.
675
+ //
676
+ // Every wrangler call runs in the site's directory, so wrangler reads the same
677
+ // `wrangler.jsonc` for each and picks the same account: its `account_id`, which
678
+ // wrangler prefers to CLOUDFLARE_ACCOUNT_ID.
679
+ async function cmdProvision(cwd, rest) {
680
+ const dryRun = rest.includes("--dry-run");
681
+ const assumeYes = rest.includes("--yes") || rest.includes("-y");
682
+ const wranglerPath = join(cwd, "wrangler.jsonc");
683
+ if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
684
+
685
+ const {
686
+ provisionPlan,
687
+ applyProvisionedId,
688
+ stagingSecretSteps,
689
+ secretNamesFromList,
690
+ TURNSTILE_TEST_SECRET,
691
+ } = await import(GENERATORS_URL);
692
+ let text = readFileSync(wranglerPath, "utf8");
693
+ const plan = provisionPlan(text);
694
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
695
+
696
+ // Which staging secrets already exist, per store, so a re-run creates nothing
697
+ // twice. Listing is read-only, so a dry run does it too.
698
+ const existing = new Map();
699
+ if (wranglerBin) {
700
+ for (const storeId of new Set(plan.secrets.filter((s) => s.create).map((s) => s.storeId))) {
701
+ const names = listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList);
702
+ if (names) existing.set(storeId, names);
703
+ }
704
+ }
705
+ const secretSteps = stagingSecretSteps(plan.secrets, existing);
706
+ const toCreate = secretSteps.filter((s) => s.status === "create");
707
+
708
+ out("astroid provision — plan:\n");
709
+ if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
710
+ for (const s of plan.steps) {
711
+ const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
712
+ out(` wrangler ${s.args.join(" ")}${note}`);
713
+ }
714
+ const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
715
+ if (!plan.hasAccount && !envAccount) {
716
+ out(
717
+ "\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
718
+ );
719
+ } else if (plan.accountId && envAccount && envAccount !== plan.accountId) {
720
+ out(
721
+ `\n ! wrangler.jsonc's account_id (${plan.accountId}) wins over ` +
722
+ `CLOUDFLARE_ACCOUNT_ID (${envAccount}), so provision uses ${plan.accountId}.`,
723
+ );
724
+ }
725
+ if (secretSteps.length > 0) {
726
+ out("\nStaging secrets it creates itself:");
727
+ for (const { secret, status } of secretSteps) {
728
+ const what =
729
+ status === "create"
730
+ ? `will create, with ${secret.create === "random" ? "a new random value" : "Turnstile's always-passes test secret"}`
731
+ : status === "exists"
732
+ ? "exists, so it's left alone"
733
+ : wranglerBin
734
+ ? "couldn't list the store, so it's left for you"
735
+ : "can't check without wrangler, so it's left for you";
736
+ out(` [staging] ${secret.secretName} in store ${secret.storeId}: ${what}`);
737
+ }
738
+ }
739
+ // The secrets a person still sets: every one provision doesn't create, and
740
+ // any it meant to but couldn't.
741
+ const printSecrets = (failed = []) => {
742
+ const byHand = [
743
+ ...plan.secrets.filter((s) => !s.create),
744
+ ...secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret),
745
+ ...failed,
746
+ ];
747
+ if (byHand.length === 0) return;
748
+ out("\nSecrets Store secrets it binds; create any that don't exist yet:");
749
+ for (const s of byHand) {
750
+ out(
751
+ ` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
752
+ `--name ${s.secretName} --scopes workers --remote`,
753
+ );
754
+ }
755
+ };
756
+
757
+ if (dryRun) {
758
+ printSecrets();
759
+ out("\n(dry run — nothing created)");
760
+ return;
761
+ }
762
+ if ((plan.steps.length > 0 || toCreate.length > 0) && !assumeYes) {
763
+ if (!process.stdin.isTTY)
764
+ fail("Refusing to create resources non-interactively. Re-run with --yes.");
765
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
766
+ const answer = (await rl.question("\nCreate the above? [y/N] ")).trim().toLowerCase();
767
+ rl.close();
768
+ if (answer !== "y" && answer !== "yes") {
769
+ out("Aborted.");
770
+ return;
771
+ }
772
+ }
773
+
774
+ if (!wranglerBin) fail("Could not find `wrangler` in this project.");
775
+ for (const s of plan.steps) {
776
+ out(`\n▸ wrangler ${s.args.join(" ")}`);
777
+ // A create that fails because the resource exists is fine: the lookup
778
+ // below finds it, so a re-run after a partial one picks up where it was.
779
+ spawnSync(process.execPath, [wranglerBin, ...s.args], { cwd, stdio: "inherit" });
780
+ if (s.kind === "r2") continue;
781
+ const id =
782
+ s.kind === "d1"
783
+ ? lookupId(
784
+ wranglerBin,
785
+ cwd,
786
+ ["d1", "list", "--json"],
787
+ (rows) => rows.find((r) => r.name === s.name)?.uuid,
788
+ )
789
+ : lookupId(
790
+ wranglerBin,
791
+ cwd,
792
+ ["kv", "namespace", "list"],
793
+ (rows) => rows.find((r) => r.title === s.name)?.id,
794
+ );
795
+ if (!id) fail(`Couldn't find the ID of ${s.name} after creating it. Fill it in by hand.`);
796
+ text = applyProvisionedId(text, s.placeholder, id);
797
+ writeFileSync(wranglerPath, text);
798
+ out(` ↳ ${s.name} = ${id}`);
799
+ }
800
+
801
+ // The value goes to wrangler as an argument of a direct spawn, never through
802
+ // a shell, and is never printed: wrangler shows it as REDACTED.
803
+ const failed = [];
804
+ for (const { secret } of toCreate) {
805
+ const args = [
806
+ "secrets-store",
807
+ "secret",
808
+ "create",
809
+ secret.storeId,
810
+ "--name",
811
+ secret.secretName,
812
+ "--scopes",
813
+ "workers",
814
+ "--remote",
815
+ ];
816
+ out(`\n▸ wrangler ${args.join(" ")} --value <${secret.create}>`);
817
+ const value =
818
+ secret.create === "random" ? randomBytes(48).toString("base64") : TURNSTILE_TEST_SECRET;
819
+ const res = spawnSync(process.execPath, [wranglerBin, ...args, "--value", value], {
820
+ cwd,
821
+ stdio: "inherit",
822
+ });
823
+ if (res.status !== 0) failed.push(secret);
824
+ }
825
+
826
+ printSecrets(failed);
827
+ const unlisted = [
828
+ ...new Set(secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret.storeId)),
829
+ ];
830
+ if (unlisted.length > 0 || failed.length > 0) {
831
+ if (unlisted.length > 0) {
832
+ out(
833
+ `\n✘ Couldn't list Secrets Store ${unlisted.join(", ")}, so its staging secrets ` +
834
+ "weren't created. Check that the store is in this account, then re-run.",
835
+ );
836
+ }
837
+ if (failed.length > 0) {
838
+ out(`\n✘ Couldn't create ${failed.map((s) => s.secretName).join(", ")}. Re-run to retry.`);
839
+ }
840
+ process.exitCode = 1;
841
+ return;
842
+ }
843
+ out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
844
+ }
845
+
846
+ /**
847
+ * The secret names in a Secrets Store, or null when it can't be listed. Wrangler
848
+ * prints a table, a page at a time, and fails on a page with no secrets, which
849
+ * is how an empty store, or the page after the last one, reads.
850
+ */
851
+ function listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList) {
852
+ const PER_PAGE = 100; // The API's maximum.
853
+ const names = new Set();
854
+ for (let page = 1; page <= 50; page++) {
855
+ const res = spawnSync(
856
+ process.execPath,
857
+ [
858
+ wranglerBin,
859
+ ...["secrets-store", "secret", "list", storeId, "--remote"],
860
+ ...["--per-page", String(PER_PAGE), "--page", String(page)],
861
+ ],
862
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
863
+ );
864
+ if (res.status !== 0) {
865
+ return `${res.stdout}${res.stderr}`.includes("returned no secrets") ? names : null;
866
+ }
867
+ const found = secretNamesFromList(res.stdout);
868
+ const before = names.size;
869
+ for (const name of found) names.add(name);
870
+ if (found.length < PER_PAGE || names.size === before) break;
871
+ }
872
+ return names;
873
+ }
874
+
875
+ // `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
876
+ // repository instead of a dashboard field. An account move once rewrote a
877
+ // site's dashboard deploy command to a bare `wrangler deploy`, and migrations
878
+ // silently stopped. Migrations run first, so new code never meets an old schema.
879
+ //
880
+ // astroid ship production the deploy/production build: migrate D1, then deploy
881
+ // astroid ship preview every other branch: migrate the staging D1, then
882
+ // `wrangler preview`, named for the branch
883
+ async function cmdShip(cwd, target) {
884
+ if (target !== "production" && target !== "preview") {
885
+ fail("Usage: astroid ship production | preview");
886
+ }
887
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
888
+ if (!wranglerBin) fail("Could not find `wrangler` in this project.");
889
+ const run = (args) => {
890
+ out(`\n▸ wrangler ${args.join(" ")}`);
891
+ const res = spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
892
+ if (res.status !== 0) process.exit(res.status ?? 1);
893
+ };
894
+ const wranglerPath = join(cwd, "wrangler.jsonc");
895
+ if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
896
+
897
+ if (target === "production") {
898
+ run(["d1", "migrations", "apply", "DB", "--remote"]);
899
+ run(["deploy"]);
900
+ return;
901
+ }
902
+
903
+ // The staging database is declared only inside `previews`, and wrangler's
904
+ // migrations command reads top-level `d1_databases`. So write a throwaway
905
+ // config naming it, derived from wrangler.jsonc on every run, rather than a
906
+ // second committed file that could drift from the binding.
907
+ const { parseJsonc } = await import(GENERATORS_URL);
908
+ const config = parseJsonc(readFileSync(wranglerPath, "utf8"));
909
+ const prodDb = (config.d1_databases ?? []).find((d) => d.binding === "DB");
910
+ const stagingDb = (config.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
911
+ if (stagingDb && prodDb) {
912
+ const migrationsDir = resolve(cwd, prodDb.migrations_dir ?? "migrations");
913
+ const tmp = join(cwd, ".wrangler", "astroid-preview-migrations.jsonc");
914
+ mkdirSync(dirname(tmp), { recursive: true });
915
+ writeFileSync(
916
+ tmp,
917
+ JSON.stringify(
918
+ {
919
+ d1_databases: [
920
+ {
921
+ binding: "PREVIEW_DB",
922
+ database_name: stagingDb.database_name,
923
+ database_id: stagingDb.database_id,
924
+ migrations_dir: migrationsDir,
925
+ },
926
+ ],
927
+ },
928
+ null,
929
+ 2,
930
+ ),
931
+ );
932
+ run(["d1", "migrations", "apply", "PREVIEW_DB", "--remote", "--config", tmp]);
933
+ } else {
934
+ out("\n(no staging D1 in `previews`, so no staging migrations to apply)");
935
+ }
936
+
937
+ // Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
938
+ // DNS label, so a branch like feature/12-login becomes feature-12-login.
939
+ const branch = process.env.WORKERS_CI_BRANCH;
940
+ const name = branch
941
+ ? branch
942
+ .toLowerCase()
943
+ .replace(/[^a-z0-9-]+/g, "-")
944
+ .replace(/^-+|-+$/g, "")
945
+ .slice(0, 63)
946
+ : undefined;
947
+ run(["preview", ...(name ? ["--name", name] : [])]);
948
+ }
949
+
604
950
  async function main() {
605
951
  const { command, flags, rest } = parseArgs(process.argv.slice(2));
606
952
  const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
@@ -622,6 +968,12 @@ async function main() {
622
968
  case "deploy":
623
969
  await cmdDeploy(cwd, flags, rest);
624
970
  break;
971
+ case "ship":
972
+ await cmdShip(cwd, rest[0]);
973
+ break;
974
+ case "provision":
975
+ await cmdProvision(cwd, rest);
976
+ break;
625
977
  case "help":
626
978
  case "--help":
627
979
  case "-h":
@@ -50,10 +50,10 @@ export function assertAuthIsolation(config) {
50
50
  return;
51
51
  if (!portal.cookiePrefix || portal.cookiePrefix === ASTROID_EDITOR_COOKIE_PREFIX) {
52
52
  throw new AstroidConfigError(`portal.cookiePrefix must be set and distinct from the editor's (${JSON.stringify(ASTROID_EDITOR_COOKIE_PREFIX)}). Two instances sharing a cookie prefix silently sign you out of one when ` +
53
- 'you sign into the other. Pick a project-specific prefix (e.g. "acme_shop").');
53
+ 'you sign into the other. Pick a project-specific prefix (for example, "acme_shop").');
54
54
  }
55
55
  if (portal.tablePrefix === ASTROID_EDITOR_TABLE_PREFIX) {
56
- throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}) — a shared table prefix merges the two instances into one user table. ` +
56
+ throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}): a shared table prefix merges the two instances into one user table. ` +
57
57
  "Leave it unset (the unprefixed `user`/`session` tables) or use a distinct prefix.");
58
58
  }
59
59
  }
@@ -109,7 +109,7 @@ export function assertCommerceRoles(commerce) {
109
109
  invoicing: "invoicing",
110
110
  pos: "locations/inventory",
111
111
  }[role];
112
- throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role — its louise-toolkit client has no ${missing} API. Providers that can: ${able.join(", ")}.`);
112
+ throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role, because its louise-toolkit client has no ${missing} API. Providers that can: ${able.join(", ")}.`);
113
113
  }
114
114
  };
115
115
  // The shorthand assigns itself to a role it can serve, so it only has to be a
@@ -145,7 +145,8 @@ export async function astroidCatalogSync(items, options) {
145
145
  }
146
146
  }
147
147
  if (items.length > 0 && result.failed === items.length) {
148
- throw new AstroidUsageError(`Catalog sync failed for all ${items.length} item(s) — nothing was written. ` +
148
+ const scope = items.length === 1 ? "the only item" : `all ${items.length} items`;
149
+ throw new AstroidUsageError(`Catalog sync failed for ${scope}, so nothing was written. ` +
149
150
  `First error: ${result.errors[0]?.message ?? "unknown"}. ` +
150
151
  "This is usually an unapplied migration (the catalog table doesn't exist yet) " +
151
152
  "or an unavailable D1 binding.");
package/dist/config.js CHANGED
@@ -102,7 +102,7 @@ function assertCrons(config) {
102
102
  }
103
103
  const owner = seen.get(expression);
104
104
  if (owner) {
105
- throw new AstroidConfigError(`Duplicate cron \`${expression}\` — it already belongs to ${owner}. One \`scheduled\` ` +
105
+ throw new AstroidConfigError(`Duplicate cron \`${expression}\`: it already belongs to ${owner}. One \`scheduled\` ` +
106
106
  "handler dispatches on the expression, so the first branch wins and this one would " +
107
107
  "never run. Use a different minute.");
108
108
  }
@@ -123,10 +123,10 @@ function assertTenancy(config) {
123
123
  return;
124
124
  const pattern = tenancy.hostPattern?.trim();
125
125
  if (!pattern) {
126
- throw new AstroidConfigError('`tenancy.hostPattern` is required, e.g. `"*.example.com"`');
126
+ throw new AstroidConfigError('`tenancy.hostPattern` is required: a wildcard such as `"*.example.com"`');
127
127
  }
128
128
  if (!pattern.startsWith("*.")) {
129
- throw new AstroidConfigError(`\`tenancy.hostPattern\` must be a wildcard starting with "*." — got "${pattern}". ` +
129
+ throw new AstroidConfigError(`\`tenancy.hostPattern\` must be a wildcard starting with "*.", but it's "${pattern}". ` +
130
130
  "A fixed host is a custom domain: put it in `hosts` instead.");
131
131
  }
132
132
  const apex = pattern.slice(2);
@@ -145,17 +145,17 @@ function assertTenancy(config) {
145
145
  // that renders the wrong page, with nothing pointing back at the config.
146
146
  for (const [label, prefix] of Object.entries(tenancy.apps ?? {})) {
147
147
  if (!label || label.includes(".")) {
148
- throw new AstroidConfigError(`\`tenancy.apps\` label "${label}" must be a single subdomain label — ` +
149
- "Cloudflare's wildcard matches one level.");
148
+ throw new AstroidConfigError(`\`tenancy.apps\` label "${label}" must be a single subdomain label, ` +
149
+ "because Cloudflare's wildcard matches one level.");
150
150
  }
151
151
  if ((tenancy.reserved ?? []).includes(label)) {
152
152
  throw new AstroidConfigError(`"${label}" is in both \`tenancy.apps\` and \`tenancy.reserved\`. ` +
153
- "An app label is implicitly reserved — keep it in `apps` only, or the " +
153
+ "An app label is implicitly reserved, so keep it in `apps` only, or the " +
154
154
  "two lists drift and `reserved` silently wins.");
155
155
  }
156
156
  if (!prefix.startsWith("/") || prefix === "/" || prefix.endsWith("/")) {
157
157
  throw new AstroidConfigError(`\`tenancy.apps.${label}\` must be an internal path prefix like "/studio" ` +
158
- `(leading slash, no trailing slash, not "/") — got "${prefix}". ` +
158
+ `(leading slash, no trailing slash, not "/"), but it's "${prefix}". ` +
159
159
  'The rewrite is `prefix + pathname`, so "/" or a trailing slash produces "//…".');
160
160
  }
161
161
  }
@@ -212,9 +212,9 @@ export function defineAstroid(config) {
212
212
  assertCrons(config);
213
213
  assertTenancy(config);
214
214
  if (config.portal?.gated) {
215
- throw new AstroidConfigError("`portal.gated` is not implemented — it is accepted but wires no guard, so the site " +
215
+ throw new AstroidConfigError("`portal.gated` is not implemented: it is accepted but wires no guard, so the site " +
216
216
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
217
- 'listing the prefixes you mean in `portal.routes` (e.g. `[{ prefix: "/" }]` with your ' +
217
+ 'listing the prefixes you mean in `portal.routes` (for example, `[{ prefix: "/" }]` with your ' +
218
218
  "login and auth paths ahead of it).");
219
219
  }
220
220
  return config;
@@ -62,8 +62,12 @@ export function generateAstroidGalleryPage(config) {
62
62
  " // No DB binding yet (pre-provision)—render the empty state.",
63
63
  "}",
64
64
  "",
65
+ "// This environment's media base: production's, or a staging Preview's own",
66
+ "// `/media`, where the Preview's uploads live.",
67
+ `const mediaBase = (env.MEDIA_URL || ${JSON.stringify(mediaBase)}).replace(/\\/+$/, "");`,
68
+ "",
65
69
  "const items: GalleryItem[] = rows.map((row) => ({",
66
- ` src: \`${mediaBase}/\${row.key}\`,`,
70
+ " src: `${mediaBase}/${row.key}`,",
67
71
  ' // An asset with no alt gets "" rather than its filename: an empty alt makes',
68
72
  " // a screen reader skip a decorative tile, while a filename is read aloud",
69
73
  " // character by character and tells the listener nothing.",
@@ -61,6 +61,7 @@ export function generateAstroidActions(config) {
61
61
  // as a literal, and an unused import is a lint error in its own file.
62
62
  ...(columnsOverride ? [] : [" ASTROID_SETTINGS_COLUMNS,"]),
63
63
  " ASTROID_SETTINGS_IMAGE_KEYS,",
64
+ " astroidMediaBase,",
64
65
  " astroidPagesCollection,",
65
66
  '} from "astroidjs";',
66
67
  'import astroidConfig from "../../astroid.config.js";',
@@ -117,7 +118,11 @@ export function generateAstroidActions(config) {
117
118
  " // than copied—a second literal here is a list that drifts from the",
118
119
  " // one the routes check against, and nothing would fail when it did.",
119
120
  ...settingsExtra,
120
- ' mediaBase: astroidConfig.deploy?.mediaBase ?? "/media",',
121
+ " // Read on each save, so a staging Preview accepts images from its own",
122
+ " // `/media` (see astroidMediaBase).",
123
+ " get mediaBase() {",
124
+ " return astroidMediaBase(astroidConfig);",
125
+ " },",
121
126
  " }),",
122
127
  " ),",
123
128
  " },",
@@ -209,10 +209,11 @@ export function generateAstroidWrangler(config) {
209
209
  p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
210
210
  p(" // summary under its own key—one small singleton blob, not worth a binding");
211
211
  p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
212
- p(" // Create each: `wrangler kv namespace create <RL|DRAFTS>`.");
212
+ p(" // Named for the project, so two sites in one account don't collide.");
213
+ p(" // `astroid provision` creates each and fills in its id.");
213
214
  p(' "kv_namespaces": [');
214
- p(' { "binding": "RL", "id": "<run: wrangler kv namespace create RL>" },');
215
- p(' { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create DRAFTS>" },');
215
+ p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
216
+ p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
216
217
  p(" ],");
217
218
  // Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
218
219
  // required member, and Better Auth's magic-link path console-logs the link in
@@ -2,3 +2,6 @@ export * from "./generate.js";
2
2
  export * from "./actions.js";
3
3
  export * from "./scaffold.js";
4
4
  export * from "./seed.js";
5
+ export * from "./previews.js";
6
+ export * from "./release.js";
7
+ export * from "./provision.js";
@@ -6,3 +6,6 @@ export * from "./generate.js";
6
6
  export * from "./actions.js";
7
7
  export * from "./scaffold.js";
8
8
  export * from "./seed.js";
9
+ export * from "./previews.js";
10
+ export * from "./release.js";
11
+ export * from "./provision.js";
@@ -0,0 +1,18 @@
1
+ /** One finding, in the order `doctor` prints them. */
2
+ export interface PreviewsFindings {
3
+ ok: string[];
4
+ errors: string[];
5
+ warnings: string[];
6
+ }
7
+ /**
8
+ * Parse JSONC: strip `//` and block comments outside strings, then trailing
9
+ * commas. `wrangler.jsonc` is written by hand and commented heavily, which is
10
+ * why `doctor` reads the rest of it by regex; this check needs the structure.
11
+ */
12
+ export declare function parseJsonc(text: string): unknown;
13
+ /**
14
+ * Check the `previews` block of a `wrangler.jsonc` against its production
15
+ * settings. Returns what passed and what didn't; an error means a Preview
16
+ * would crash or touch production.
17
+ */
18
+ export declare function checkWranglerPreviews(text: string): PreviewsFindings;