astroidjs 0.16.0 → 0.18.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/bin/astroid.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // astroid generate [--config <path>] [--cwd <dir>] regenerate schema/worker/middleware from the config
7
7
  // astroid doctor [--config <path>] [--cwd <dir>] validate config + bindings + generated-file freshness
8
8
  // astroid dev [...astro args] generate, then `astro dev`
9
- // astroid build [...astro args] generate, then `astro build`
9
+ // astroid build [...astro args] generate, `astro build`, then fix the built wrangler.json
10
10
  // astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
11
11
  // astroid ship production | preview migrate, then deploy or preview (Workers Builds)
12
12
  //
@@ -16,6 +16,7 @@
16
16
  // CLI ships in, no dependency on node_modules layout (mirrors the louise bin).
17
17
 
18
18
  import { execFileSync, spawn, spawnSync } from "node:child_process";
19
+ import { randomBytes } from "node:crypto";
19
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
21
  import { createRequire } from "node:module";
21
22
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -156,6 +157,8 @@ async function cmdDoctor(cwd, flags) {
156
157
  astroidUsesQueues,
157
158
  astroidCrons,
158
159
  checkWranglerPreviews,
160
+ astroidRunsMigrations,
161
+ migrationsOwnershipError,
159
162
  generateAstroidReleaseWorkflow,
160
163
  ASTROID_RELEASE_WORKFLOW_PATH,
161
164
  } = await import(GENERATORS_URL);
@@ -325,12 +328,21 @@ async function cmdDoctor(cwd, flags) {
325
328
  // 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
326
329
  // or `migrations/`, the generated default, when it names none. A site that
327
330
  // keeps its migrations under another name (drizzle/) isn't missing them.
331
+ // An app with `deploy.migrations: false` applies none, because another
332
+ // app owns the database's schema, so it needs no directory. It must not
333
+ // name one either: that contradiction means someone expects it to migrate.
328
334
  const wranglerText = existsSync(wranglerPath) ? readFileSync(wranglerPath, "utf8") : "";
329
- const migrationsDir =
330
- wranglerText.match(/"migrations_dir"\s*:\s*"([^"]+)"/)?.[1]?.replace(/\/+$/, "") ??
331
- "migrations";
332
- if (existsSync(join(cwd, migrationsDir))) ok(`${migrationsDir}/ directory present`);
333
- else warn(`no ${migrationsDir}/ directory — create your D1 schema migrations there.`);
335
+ if (!astroidRunsMigrations(config)) {
336
+ const ownership = migrationsOwnershipError(config, wranglerText);
337
+ if (ownership) err(ownership);
338
+ else ok("deploy.migrations is false: another app migrates this database");
339
+ } else {
340
+ const migrationsDir =
341
+ wranglerText.match(/"migrations_dir"\s*:\s*"([^"]+)"/)?.[1]?.replace(/\/+$/, "") ??
342
+ "migrations";
343
+ if (existsSync(join(cwd, migrationsDir))) ok(`${migrationsDir}/ directory present`);
344
+ else warn(`no ${migrationsDir}/ directory — create your D1 schema migrations there.`);
345
+ }
334
346
 
335
347
  // 4. Local secret provisioning—which modules will run dormant under
336
348
  // `astroid dev`, and what to set to wake them.
@@ -397,7 +409,33 @@ async function cmdAstro(cwd, subcommand, flags, rest) {
397
409
  );
398
410
  }
399
411
  const child = spawn(process.execPath, [astroBin, subcommand, ...rest], { stdio: "inherit", cwd });
400
- child.on("exit", (code) => process.exit(code ?? 0));
412
+ child.on("exit", async (code) => {
413
+ // A failed build keeps its exit code and its output as it left them.
414
+ if (subcommand === "build" && code === 0) await fixBuiltWranglerConfig(cwd);
415
+ process.exit(code ?? 0);
416
+ });
417
+ }
418
+
419
+ /** Delete `legacy_env` from the built Wrangler config, which current Wrangler
420
+ * rejects (see src/project/build-output.ts). Warns rather than failing: the
421
+ * build itself succeeded, and an older Wrangler deploys the file as is. */
422
+ async function fixBuiltWranglerConfig(cwd) {
423
+ const { WRANGLER_DEPLOY_REDIRECT, builtWranglerConfigPath, stripLegacyEnv } = await import(
424
+ GENERATORS_URL
425
+ );
426
+ const redirectPath = join(cwd, WRANGLER_DEPLOY_REDIRECT);
427
+ const redirect = existsSync(redirectPath) ? readFileSync(redirectPath, "utf8") : null;
428
+ const configPath = resolve(cwd, builtWranglerConfigPath(redirect));
429
+ try {
430
+ const next = stripLegacyEnv(readFileSync(configPath, "utf8"));
431
+ if (next === null) return;
432
+ writeFileSync(configPath, next);
433
+ out(`astroid: removed legacy_env from ${rel(cwd, configPath)}`);
434
+ } catch (err) {
435
+ process.stderr.write(
436
+ `astroid: warning: couldn't check ${rel(cwd, configPath)} for legacy_env: ${err instanceof Error ? err.message : String(err)}\n`,
437
+ );
438
+ }
401
439
  }
402
440
 
403
441
  /** Resolve a project-local CLI bin (astro, wrangler) to an absolute path via the
@@ -500,7 +538,9 @@ async function cmdDeploy(cwd, flags, rest) {
500
538
  const assumeYes = rest.includes("--yes") || rest.includes("-y");
501
539
  const remoteArgs = rest.includes("--local") ? [] : ["--remote"];
502
540
 
503
- await loadConfig(cwd, flags.config); // validates the config (throws on a bad shape)
541
+ const { config } = await loadConfig(cwd, flags.config); // validates the shape, or throws
542
+ const { astroidRunsMigrations, ASTROID_SKIP_MIGRATIONS_NOTE } = await import(GENERATORS_URL);
543
+ const migrate = astroidRunsMigrations(config);
504
544
  const wranglerPath = join(cwd, "wrangler.jsonc");
505
545
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
506
546
 
@@ -520,7 +560,11 @@ async function cmdDeploy(cwd, flags, rest) {
520
560
  out(" Provision:");
521
561
  if (plan.length === 0) out(" (all bindings already have ids)");
522
562
  for (const s of plan) out(` wrangler ${s.args.join(" ")}`);
523
- out(`\n Migrate: wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
563
+ out(
564
+ migrate
565
+ ? `\n Migrate: wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd()
566
+ : `\n Migrate: ${ASTROID_SKIP_MIGRATIONS_NOTE}`,
567
+ );
524
568
  out(" Secrets: wrangler secret put SESSION_SECRET (prompted)");
525
569
  out(" Deploy: wrangler deploy\n");
526
570
  if (!facts.hasAccount) {
@@ -601,10 +645,14 @@ async function cmdDeploy(cwd, flags, rest) {
601
645
  }
602
646
  }
603
647
 
604
- // 2) Migrations.
605
- out(`\n▸ wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
606
- if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0)
607
- fail("Migrations failed.");
648
+ // 2) Migrations, unless another app owns this database's schema.
649
+ if (!migrate) {
650
+ out(`\n${ASTROID_SKIP_MIGRATIONS_NOTE}`);
651
+ } else {
652
+ out(`\n▸ wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
653
+ if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0)
654
+ fail("Migrations failed.");
655
+ }
608
656
 
609
657
  // 3) Secrets (interactive; wrangler prompts for the value).
610
658
  out("\n▸ wrangler secret put SESSION_SECRET");
@@ -656,7 +704,7 @@ Usage:
656
704
  astroid generate [--config <path>] [--cwd <dir>] regenerate src/schema.ts, src/worker.ts, src/middleware.ts
657
705
  astroid doctor [--config <path>] [--cwd <dir>] validate config, bindings, and generated-file freshness
658
706
  astroid dev [...astro args] regenerate, then run \`astro dev\`
659
- astroid build [...astro args] regenerate, then run \`astro build\`
707
+ astroid build [...astro args] regenerate, run \`astro build\`, then drop legacy_env from its Wrangler config
660
708
  astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
661
709
  astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
662
710
  astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
@@ -667,17 +715,42 @@ New project: pnpm create astroid@latest
667
715
  // `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
668
716
  // top level and `previews` alike, and writes each new ID back in place of its
669
717
  // placeholder. It never deploys, so it's safe to run before a site's first
670
- // release, and re-running it only creates what's still missing. Secrets and
671
- // dashboard settings need a person, so it prints those instead.
718
+ // release, and re-running it only creates what's still missing. It creates the
719
+ // staging secrets that need no person (a random SESSION_SECRET and Turnstile's
720
+ // test secret), skipping any the store already has. Every other secret, and
721
+ // dashboard settings, need a person, so it prints those instead.
722
+ //
723
+ // Every wrangler call runs in the site's directory, so wrangler reads the same
724
+ // `wrangler.jsonc` for each and picks the same account: its `account_id`, which
725
+ // wrangler prefers to CLOUDFLARE_ACCOUNT_ID.
672
726
  async function cmdProvision(cwd, rest) {
673
727
  const dryRun = rest.includes("--dry-run");
674
728
  const assumeYes = rest.includes("--yes") || rest.includes("-y");
675
729
  const wranglerPath = join(cwd, "wrangler.jsonc");
676
730
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
677
731
 
678
- const { provisionPlan, applyProvisionedId } = await import(GENERATORS_URL);
732
+ const {
733
+ provisionPlan,
734
+ applyProvisionedId,
735
+ stagingSecretSteps,
736
+ secretNamesFromList,
737
+ TURNSTILE_TEST_SECRET,
738
+ } = await import(GENERATORS_URL);
679
739
  let text = readFileSync(wranglerPath, "utf8");
680
740
  const plan = provisionPlan(text);
741
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
742
+
743
+ // Which staging secrets already exist, per store, so a re-run creates nothing
744
+ // twice. Listing is read-only, so a dry run does it too.
745
+ const existing = new Map();
746
+ if (wranglerBin) {
747
+ for (const storeId of new Set(plan.secrets.filter((s) => s.create).map((s) => s.storeId))) {
748
+ const names = listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList);
749
+ if (names) existing.set(storeId, names);
750
+ }
751
+ }
752
+ const secretSteps = stagingSecretSteps(plan.secrets, existing);
753
+ const toCreate = secretSteps.filter((s) => s.status === "create");
681
754
 
682
755
  out("astroid provision — plan:\n");
683
756
  if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
@@ -685,15 +758,42 @@ async function cmdProvision(cwd, rest) {
685
758
  const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
686
759
  out(` wrangler ${s.args.join(" ")}${note}`);
687
760
  }
688
- if (!plan.hasAccount && !process.env.CLOUDFLARE_ACCOUNT_ID) {
761
+ const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
762
+ if (!plan.hasAccount && !envAccount) {
689
763
  out(
690
764
  "\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
691
765
  );
766
+ } else if (plan.accountId && envAccount && envAccount !== plan.accountId) {
767
+ out(
768
+ `\n ! wrangler.jsonc's account_id (${plan.accountId}) wins over ` +
769
+ `CLOUDFLARE_ACCOUNT_ID (${envAccount}), so provision uses ${plan.accountId}.`,
770
+ );
692
771
  }
693
- const printSecrets = () => {
694
- if (plan.secrets.length === 0) return;
772
+ if (secretSteps.length > 0) {
773
+ out("\nStaging secrets it creates itself:");
774
+ for (const { secret, status } of secretSteps) {
775
+ const what =
776
+ status === "create"
777
+ ? `will create, with ${secret.create === "random" ? "a new random value" : "Turnstile's always-passes test secret"}`
778
+ : status === "exists"
779
+ ? "exists, so it's left alone"
780
+ : wranglerBin
781
+ ? "couldn't list the store, so it's left for you"
782
+ : "can't check without wrangler, so it's left for you";
783
+ out(` [staging] ${secret.secretName} in store ${secret.storeId}: ${what}`);
784
+ }
785
+ }
786
+ // The secrets a person still sets: every one provision doesn't create, and
787
+ // any it meant to but couldn't.
788
+ const printSecrets = (failed = []) => {
789
+ const byHand = [
790
+ ...plan.secrets.filter((s) => !s.create),
791
+ ...secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret),
792
+ ...failed,
793
+ ];
794
+ if (byHand.length === 0) return;
695
795
  out("\nSecrets Store secrets it binds; create any that don't exist yet:");
696
- for (const s of plan.secrets) {
796
+ for (const s of byHand) {
697
797
  out(
698
798
  ` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
699
799
  `--name ${s.secretName} --scopes workers --remote`,
@@ -706,7 +806,7 @@ async function cmdProvision(cwd, rest) {
706
806
  out("\n(dry run — nothing created)");
707
807
  return;
708
808
  }
709
- if (plan.steps.length > 0 && !assumeYes) {
809
+ if ((plan.steps.length > 0 || toCreate.length > 0) && !assumeYes) {
710
810
  if (!process.stdin.isTTY)
711
811
  fail("Refusing to create resources non-interactively. Re-run with --yes.");
712
812
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -718,7 +818,6 @@ async function cmdProvision(cwd, rest) {
718
818
  }
719
819
  }
720
820
 
721
- const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
722
821
  if (!wranglerBin) fail("Could not find `wrangler` in this project.");
723
822
  for (const s of plan.steps) {
724
823
  out(`\n▸ wrangler ${s.args.join(" ")}`);
@@ -745,83 +844,123 @@ async function cmdProvision(cwd, rest) {
745
844
  writeFileSync(wranglerPath, text);
746
845
  out(` ↳ ${s.name} = ${id}`);
747
846
  }
748
- printSecrets();
847
+
848
+ // The value goes to wrangler as an argument of a direct spawn, never through
849
+ // a shell, and is never printed: wrangler shows it as REDACTED.
850
+ const failed = [];
851
+ for (const { secret } of toCreate) {
852
+ const args = [
853
+ "secrets-store",
854
+ "secret",
855
+ "create",
856
+ secret.storeId,
857
+ "--name",
858
+ secret.secretName,
859
+ "--scopes",
860
+ "workers",
861
+ "--remote",
862
+ ];
863
+ out(`\n▸ wrangler ${args.join(" ")} --value <${secret.create}>`);
864
+ const value =
865
+ secret.create === "random" ? randomBytes(48).toString("base64") : TURNSTILE_TEST_SECRET;
866
+ const res = spawnSync(process.execPath, [wranglerBin, ...args, "--value", value], {
867
+ cwd,
868
+ stdio: "inherit",
869
+ });
870
+ if (res.status !== 0) failed.push(secret);
871
+ }
872
+
873
+ printSecrets(failed);
874
+ const unlisted = [
875
+ ...new Set(secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret.storeId)),
876
+ ];
877
+ if (unlisted.length > 0 || failed.length > 0) {
878
+ if (unlisted.length > 0) {
879
+ out(
880
+ `\n✘ Couldn't list Secrets Store ${unlisted.join(", ")}, so its staging secrets ` +
881
+ "weren't created. Check that the store is in this account, then re-run.",
882
+ );
883
+ }
884
+ if (failed.length > 0) {
885
+ out(`\n✘ Couldn't create ${failed.map((s) => s.secretName).join(", ")}. Re-run to retry.`);
886
+ }
887
+ process.exitCode = 1;
888
+ return;
889
+ }
749
890
  out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
750
891
  }
751
892
 
893
+ /**
894
+ * The secret names in a Secrets Store, or null when it can't be listed. Wrangler
895
+ * prints a table, a page at a time, and fails on a page with no secrets, which
896
+ * is how an empty store, or the page after the last one, reads.
897
+ */
898
+ function listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList) {
899
+ const PER_PAGE = 100; // The API's maximum.
900
+ const names = new Set();
901
+ for (let page = 1; page <= 50; page++) {
902
+ const res = spawnSync(
903
+ process.execPath,
904
+ [
905
+ wranglerBin,
906
+ ...["secrets-store", "secret", "list", storeId, "--remote"],
907
+ ...["--per-page", String(PER_PAGE), "--page", String(page)],
908
+ ],
909
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
910
+ );
911
+ if (res.status !== 0) {
912
+ return `${res.stdout}${res.stderr}`.includes("returned no secrets") ? names : null;
913
+ }
914
+ const found = secretNamesFromList(res.stdout);
915
+ const before = names.size;
916
+ for (const name of found) names.add(name);
917
+ if (found.length < PER_PAGE || names.size === before) break;
918
+ }
919
+ return names;
920
+ }
921
+
752
922
  // `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
753
923
  // repository instead of a dashboard field. An account move once rewrote a
754
924
  // site's dashboard deploy command to a bare `wrangler deploy`, and migrations
755
- // silently stopped. Migrations run first, so new code never meets an old schema.
925
+ // silently stopped. Migrations run first, so new code never meets an old schema,
926
+ // unless `deploy.migrations` is false because another app owns the database.
756
927
  //
757
928
  // astroid ship production the deploy/production build: migrate D1, then deploy
758
929
  // astroid ship preview every other branch: migrate the staging D1, then
759
930
  // `wrangler preview`, named for the branch
760
- async function cmdShip(cwd, target) {
931
+ async function cmdShip(cwd, target, flags) {
761
932
  if (target !== "production" && target !== "preview") {
762
933
  fail("Usage: astroid ship production | preview");
763
934
  }
764
935
  const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
765
936
  if (!wranglerBin) fail("Could not find `wrangler` in this project.");
766
- const run = (args) => {
767
- out(`\n▸ wrangler ${args.join(" ")}`);
768
- const res = spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
769
- if (res.status !== 0) process.exit(res.status ?? 1);
770
- };
771
937
  const wranglerPath = join(cwd, "wrangler.jsonc");
772
938
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
773
939
 
774
- if (target === "production") {
775
- run(["d1", "migrations", "apply", "DB", "--remote"]);
776
- run(["deploy"]);
777
- return;
940
+ // The plan is pure and tested in src/project/ship.ts; this only runs it.
941
+ const { astroidShipPlan } = await import(GENERATORS_URL);
942
+ const { config } = await loadConfig(cwd, flags.config);
943
+ const steps = astroidShipPlan(target, config, {
944
+ wrangler: readFileSync(wranglerPath, "utf8"),
945
+ root: cwd,
946
+ branch: process.env.WORKERS_CI_BRANCH,
947
+ });
948
+ for (const step of steps) {
949
+ if (step.note) {
950
+ out(`\n${step.note}`);
951
+ } else if (step.write) {
952
+ const abs = join(cwd, step.write.path);
953
+ mkdirSync(dirname(abs), { recursive: true });
954
+ writeFileSync(abs, step.write.contents);
955
+ } else {
956
+ out(`\n▸ wrangler ${step.run.join(" ")}`);
957
+ const res = spawnSync(process.execPath, [wranglerBin, ...step.run], {
958
+ cwd,
959
+ stdio: "inherit",
960
+ });
961
+ if (res.status !== 0) process.exit(res.status ?? 1);
962
+ }
778
963
  }
779
-
780
- // The staging database is declared only inside `previews`, and wrangler's
781
- // migrations command reads top-level `d1_databases`. So write a throwaway
782
- // config naming it, derived from wrangler.jsonc on every run, rather than a
783
- // second committed file that could drift from the binding.
784
- const { parseJsonc } = await import(GENERATORS_URL);
785
- const config = parseJsonc(readFileSync(wranglerPath, "utf8"));
786
- const prodDb = (config.d1_databases ?? []).find((d) => d.binding === "DB");
787
- const stagingDb = (config.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
788
- if (stagingDb && prodDb) {
789
- const migrationsDir = resolve(cwd, prodDb.migrations_dir ?? "migrations");
790
- const tmp = join(cwd, ".wrangler", "astroid-preview-migrations.jsonc");
791
- mkdirSync(dirname(tmp), { recursive: true });
792
- writeFileSync(
793
- tmp,
794
- JSON.stringify(
795
- {
796
- d1_databases: [
797
- {
798
- binding: "PREVIEW_DB",
799
- database_name: stagingDb.database_name,
800
- database_id: stagingDb.database_id,
801
- migrations_dir: migrationsDir,
802
- },
803
- ],
804
- },
805
- null,
806
- 2,
807
- ),
808
- );
809
- run(["d1", "migrations", "apply", "PREVIEW_DB", "--remote", "--config", tmp]);
810
- } else {
811
- out("\n(no staging D1 in `previews`, so no staging migrations to apply)");
812
- }
813
-
814
- // Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
815
- // DNS label, so a branch like feature/12-login becomes feature-12-login.
816
- const branch = process.env.WORKERS_CI_BRANCH;
817
- const name = branch
818
- ? branch
819
- .toLowerCase()
820
- .replace(/[^a-z0-9-]+/g, "-")
821
- .replace(/^-+|-+$/g, "")
822
- .slice(0, 63)
823
- : undefined;
824
- run(["preview", ...(name ? ["--name", name] : [])]);
825
964
  }
826
965
 
827
966
  async function main() {
@@ -846,7 +985,7 @@ async function main() {
846
985
  await cmdDeploy(cwd, flags, rest);
847
986
  break;
848
987
  case "ship":
849
- await cmdShip(cwd, rest[0]);
988
+ await cmdShip(cwd, rest[0], flags);
850
989
  break;
851
990
  case "provision":
852
991
  await cmdProvision(cwd, rest);
@@ -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.d.ts CHANGED
@@ -207,6 +207,12 @@ export interface QueuesConfig {
207
207
  cron?: string | false;
208
208
  /** Deliveries before Cloudflare routes a message to the DLQ. Default 5. */
209
209
  maxRetries?: number;
210
+ /**
211
+ * Seconds Cloudflare waits before redelivering a failed message. Default
212
+ * 30. The queue owns retries, so keep a handler's own client retries off
213
+ * rather than stacking them on top of these.
214
+ */
215
+ retryDelay?: number;
210
216
  /** Messages per consumer invocation. Default 10. */
211
217
  maxBatchSize?: number;
212
218
  /** Seconds the consumer waits to fill a batch. Default 30. */
@@ -451,11 +457,29 @@ export interface PagesConfig {
451
457
  */
452
458
  hooks?: boolean;
453
459
  }
460
+ export interface StatusConfig {
461
+ /**
462
+ * Add the site's own checks to the public status route, from the
463
+ * scaffold-once `src/status-checks.ts`: for example, the catalog snapshot's
464
+ * age, or the last health scan's. The generated worker spreads them after
465
+ * Astroid's own `d1` and `content` checks.
466
+ */
467
+ checks?: boolean;
468
+ }
454
469
  export interface DeployConfig {
455
470
  platform: "cloudflare";
456
471
  /** Media base for R2 + `cf-image` resizing—matches Louise's media route
457
472
  * (`media.<brand>/cdn-cgi/image`). Default `"/media"`. */
458
473
  mediaBase?: string;
474
+ /**
475
+ * Whether `astroid ship` applies D1 migrations before it deploys. Default
476
+ * `true`. Set `false` for an app whose database another app migrates, such
477
+ * as a second Worker in the same repository that binds the first one's D1.
478
+ * One app owns a database's schema. When both migrate, one release tag runs
479
+ * two `wrangler d1 migrations apply` at once against one ledger, and a
480
+ * non-idempotent statement fails the second deploy.
481
+ */
482
+ migrations?: boolean;
459
483
  }
460
484
  export interface AstroidConfig {
461
485
  /**
@@ -531,6 +555,8 @@ export interface AstroidConfig {
531
555
  media?: MediaConfig;
532
556
  /** The editable `pages` collection's site-owned write hooks. */
533
557
  pages?: PagesConfig;
558
+ /** The public status route's site-owned checks. */
559
+ status?: StatusConfig;
534
560
  /**
535
561
  * Force the contact form + `inquiries` table on or off. Omit to detect from
536
562
  * the config (a `contact` section, or a wholesale-inquiry module). Set `true`
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;
@@ -0,0 +1,18 @@
1
+ /** Where `@astrojs/cloudflare` writes the built config when there's no redirect. */
2
+ export declare const ASTROID_BUILT_WRANGLER_CONFIG = "dist/server/wrangler.json";
3
+ /** The redirect file Wrangler reads to find the built config. */
4
+ export declare const WRANGLER_DEPLOY_REDIRECT = ".wrangler/deploy/config.json";
5
+ /**
6
+ * The built config's path, relative to the project root, found the way
7
+ * Wrangler finds it: the `configPath` in `.wrangler/deploy/config.json`, which
8
+ * is relative to that file's own folder, else {@link ASTROID_BUILT_WRANGLER_CONFIG}.
9
+ * Pass the redirect's text, or `null` when there isn't one.
10
+ */
11
+ export declare function builtWranglerConfigPath(redirect: string | null): string;
12
+ /**
13
+ * Remove `legacy_env` from a built Wrangler config. Returns the new text, or
14
+ * `null` when the field isn't there, so the caller leaves the file untouched.
15
+ * Keeps the file's indentation and every other key in its order. Throws when
16
+ * the text isn't a JSON object.
17
+ */
18
+ export declare function stripLegacyEnv(text: string): string | null;