astroidjs 0.17.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
  //
@@ -157,6 +157,8 @@ async function cmdDoctor(cwd, flags) {
157
157
  astroidUsesQueues,
158
158
  astroidCrons,
159
159
  checkWranglerPreviews,
160
+ astroidRunsMigrations,
161
+ migrationsOwnershipError,
160
162
  generateAstroidReleaseWorkflow,
161
163
  ASTROID_RELEASE_WORKFLOW_PATH,
162
164
  } = await import(GENERATORS_URL);
@@ -326,12 +328,21 @@ async function cmdDoctor(cwd, flags) {
326
328
  // 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
327
329
  // or `migrations/`, the generated default, when it names none. A site that
328
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.
329
334
  const wranglerText = existsSync(wranglerPath) ? readFileSync(wranglerPath, "utf8") : "";
330
- const migrationsDir =
331
- wranglerText.match(/"migrations_dir"\s*:\s*"([^"]+)"/)?.[1]?.replace(/\/+$/, "") ??
332
- "migrations";
333
- if (existsSync(join(cwd, migrationsDir))) ok(`${migrationsDir}/ directory present`);
334
- 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
+ }
335
346
 
336
347
  // 4. Local secret provisioning—which modules will run dormant under
337
348
  // `astroid dev`, and what to set to wake them.
@@ -398,7 +409,33 @@ async function cmdAstro(cwd, subcommand, flags, rest) {
398
409
  );
399
410
  }
400
411
  const child = spawn(process.execPath, [astroBin, subcommand, ...rest], { stdio: "inherit", cwd });
401
- 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
+ }
402
439
  }
403
440
 
404
441
  /** Resolve a project-local CLI bin (astro, wrangler) to an absolute path via the
@@ -501,7 +538,9 @@ async function cmdDeploy(cwd, flags, rest) {
501
538
  const assumeYes = rest.includes("--yes") || rest.includes("-y");
502
539
  const remoteArgs = rest.includes("--local") ? [] : ["--remote"];
503
540
 
504
- 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);
505
544
  const wranglerPath = join(cwd, "wrangler.jsonc");
506
545
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
507
546
 
@@ -521,7 +560,11 @@ async function cmdDeploy(cwd, flags, rest) {
521
560
  out(" Provision:");
522
561
  if (plan.length === 0) out(" (all bindings already have ids)");
523
562
  for (const s of plan) out(` wrangler ${s.args.join(" ")}`);
524
- 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
+ );
525
568
  out(" Secrets: wrangler secret put SESSION_SECRET (prompted)");
526
569
  out(" Deploy: wrangler deploy\n");
527
570
  if (!facts.hasAccount) {
@@ -602,10 +645,14 @@ async function cmdDeploy(cwd, flags, rest) {
602
645
  }
603
646
  }
604
647
 
605
- // 2) Migrations.
606
- out(`\n▸ wrangler d1 migrations apply DB ${remoteArgs.join(" ")}`.trimEnd());
607
- if (runInherit(["d1", "migrations", "apply", "DB", ...remoteArgs]).status !== 0)
608
- 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
+ }
609
656
 
610
657
  // 3) Secrets (interactive; wrangler prompts for the value).
611
658
  out("\n▸ wrangler secret put SESSION_SECRET");
@@ -657,7 +704,7 @@ Usage:
657
704
  astroid generate [--config <path>] [--cwd <dir>] regenerate src/schema.ts, src/worker.ts, src/middleware.ts
658
705
  astroid doctor [--config <path>] [--cwd <dir>] validate config, bindings, and generated-file freshness
659
706
  astroid dev [...astro args] regenerate, then run \`astro dev\`
660
- 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
661
708
  astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
662
709
  astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
663
710
  astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
@@ -875,76 +922,45 @@ function listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList) {
875
922
  // `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
876
923
  // repository instead of a dashboard field. An account move once rewrote a
877
924
  // 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.
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.
879
927
  //
880
928
  // astroid ship production the deploy/production build: migrate D1, then deploy
881
929
  // astroid ship preview every other branch: migrate the staging D1, then
882
930
  // `wrangler preview`, named for the branch
883
- async function cmdShip(cwd, target) {
931
+ async function cmdShip(cwd, target, flags) {
884
932
  if (target !== "production" && target !== "preview") {
885
933
  fail("Usage: astroid ship production | preview");
886
934
  }
887
935
  const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
888
936
  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
937
  const wranglerPath = join(cwd, "wrangler.jsonc");
895
938
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
896
939
 
897
- if (target === "production") {
898
- run(["d1", "migrations", "apply", "DB", "--remote"]);
899
- run(["deploy"]);
900
- 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
+ }
901
963
  }
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
964
  }
949
965
 
950
966
  async function main() {
@@ -969,7 +985,7 @@ async function main() {
969
985
  await cmdDeploy(cwd, flags, rest);
970
986
  break;
971
987
  case "ship":
972
- await cmdShip(cwd, rest[0]);
988
+ await cmdShip(cwd, rest[0], flags);
973
989
  break;
974
990
  case "provision":
975
991
  await cmdProvision(cwd, rest);
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`
@@ -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;
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
+ //
3
+ // The post-build fix `astroid build` applies to the Worker config that
4
+ // `@astrojs/cloudflare` writes.
5
+ //
6
+ // The adapter writes the built config from the Wrangler version it bundles, and
7
+ // some of those versions include `legacy_env: true`. Current Wrangler rejects
8
+ // the field, so a Workers Builds deploy running a newer Wrangler fails on the
9
+ // build's own output. `true` was always the default, so deleting the field
10
+ // changes nothing else.
11
+ //
12
+ // Pure: text in, text out, so it's tested directly and the CLI only does I/O.
13
+ /** Where `@astrojs/cloudflare` writes the built config when there's no redirect. */
14
+ export const ASTROID_BUILT_WRANGLER_CONFIG = "dist/server/wrangler.json";
15
+ /** The redirect file Wrangler reads to find the built config. */
16
+ export const WRANGLER_DEPLOY_REDIRECT = ".wrangler/deploy/config.json";
17
+ /**
18
+ * The built config's path, relative to the project root, found the way
19
+ * Wrangler finds it: the `configPath` in `.wrangler/deploy/config.json`, which
20
+ * is relative to that file's own folder, else {@link ASTROID_BUILT_WRANGLER_CONFIG}.
21
+ * Pass the redirect's text, or `null` when there isn't one.
22
+ */
23
+ export function builtWranglerConfigPath(redirect) {
24
+ if (redirect === null)
25
+ return ASTROID_BUILT_WRANGLER_CONFIG;
26
+ let configPath;
27
+ try {
28
+ configPath = JSON.parse(redirect).configPath;
29
+ }
30
+ catch {
31
+ return ASTROID_BUILT_WRANGLER_CONFIG;
32
+ }
33
+ if (typeof configPath !== "string" || !configPath)
34
+ return ASTROID_BUILT_WRANGLER_CONFIG;
35
+ if (configPath.startsWith("/"))
36
+ return configPath;
37
+ // Resolved against `.wrangler/deploy/`, the redirect's folder.
38
+ const parts = [".wrangler", "deploy"];
39
+ for (const segment of configPath.split("/")) {
40
+ if (segment === "..")
41
+ parts.pop();
42
+ else if (segment !== "." && segment !== "")
43
+ parts.push(segment);
44
+ }
45
+ return parts.join("/");
46
+ }
47
+ /**
48
+ * Remove `legacy_env` from a built Wrangler config. Returns the new text, or
49
+ * `null` when the field isn't there, so the caller leaves the file untouched.
50
+ * Keeps the file's indentation and every other key in its order. Throws when
51
+ * the text isn't a JSON object.
52
+ */
53
+ export function stripLegacyEnv(text) {
54
+ const config = JSON.parse(text);
55
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
56
+ throw new TypeError("The built Wrangler config isn't a JSON object.");
57
+ }
58
+ if (!Object.hasOwn(config, "legacy_env"))
59
+ return null;
60
+ const { legacy_env: _removed, ...rest } = config;
61
+ const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? "";
62
+ const trailing = /\r?\n$/.exec(text)?.[0] ?? "";
63
+ return JSON.stringify(rest, null, indent) + trailing;
64
+ }
@@ -5,6 +5,25 @@ export interface GeneratedFile {
5
5
  path: string;
6
6
  contents: string;
7
7
  }
8
+ /**
9
+ * The paths of the regenerated trio, relative to the project root. Astroid's
10
+ * code, tested here, not the site's: spread it into a site's
11
+ * `coverage.exclude` so an Astroid upgrade that adds lines to them doesn't
12
+ * move the site's coverage.
13
+ *
14
+ * ```ts
15
+ * // vitest.config.ts
16
+ * import { ASTROID_GENERATED_FILES } from "astroidjs";
17
+ *
18
+ * export default defineConfig({
19
+ * test: { coverage: { exclude: [...ASTROID_GENERATED_FILES] } },
20
+ * });
21
+ * ```
22
+ *
23
+ * A site that lists them by hand would miss a generated file a later release
24
+ * adds; this list gains it.
25
+ */
26
+ export declare const ASTROID_GENERATED_FILES: readonly ["src/schema.ts", "src/worker.ts", "src/middleware.ts"];
8
27
  /**
9
28
  * The regenerated trio—the files that are a pure function of the Astroid config
10
29
  * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
@@ -18,12 +18,36 @@ import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index
18
18
  import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
19
19
  import { astroidCommerceProviders } from "../commerce/roles.js";
20
20
  import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames, } from "../commerce/secrets.js";
21
- import { ASTROID_QUEUE_BINDING, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
21
+ import { ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
22
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
23
  import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
24
24
  import { tenancyZone } from "../tenancy/index.js";
25
25
  import { generateAstroidSchema } from "../schema/generate.js";
26
+ import { ASTROID_AI_GATEWAY_VAR } from "../worker/gateway.js";
26
27
  import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
28
+ /**
29
+ * The paths of the regenerated trio, relative to the project root. Astroid's
30
+ * code, tested here, not the site's: spread it into a site's
31
+ * `coverage.exclude` so an Astroid upgrade that adds lines to them doesn't
32
+ * move the site's coverage.
33
+ *
34
+ * ```ts
35
+ * // vitest.config.ts
36
+ * import { ASTROID_GENERATED_FILES } from "astroidjs";
37
+ *
38
+ * export default defineConfig({
39
+ * test: { coverage: { exclude: [...ASTROID_GENERATED_FILES] } },
40
+ * });
41
+ * ```
42
+ *
43
+ * A site that lists them by hand would miss a generated file a later release
44
+ * adds; this list gains it.
45
+ */
46
+ export const ASTROID_GENERATED_FILES = [
47
+ "src/schema.ts",
48
+ "src/worker.ts",
49
+ "src/middleware.ts",
50
+ ];
27
51
  /**
28
52
  * The regenerated trio—the files that are a pure function of the Astroid config
29
53
  * and carry a "do not hand-edit" banner. `astroid generate` writes exactly these,
@@ -31,10 +55,11 @@ import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/gene
31
55
  * once files (wrangler.jsonc, astro.config, auth.ts) are NOT here by design.
32
56
  */
33
57
  export function generateAstroidProject(config) {
58
+ const [schema, worker, middleware] = ASTROID_GENERATED_FILES;
34
59
  return [
35
- { path: "src/schema.ts", contents: generateAstroidSchema(config) },
36
- { path: "src/worker.ts", contents: generateAstroidWorker(config) },
37
- { path: "src/middleware.ts", contents: generateAstroidMiddleware(config) },
60
+ { path: schema, contents: generateAstroidSchema(config) },
61
+ { path: worker, contents: generateAstroidWorker(config) },
62
+ { path: middleware, contents: generateAstroidMiddleware(config) },
38
63
  ];
39
64
  }
40
65
  /**
@@ -174,6 +199,10 @@ export function generateAstroidWrangler(config) {
174
199
  p(` "max_batch_size": ${config.queues?.maxBatchSize ?? 10},`);
175
200
  p(` "max_batch_timeout": ${config.queues?.maxBatchTimeout ?? 30},`);
176
201
  p(` "max_retries": ${config.queues?.maxRetries ?? 5},`);
202
+ // A wait between deliveries, so a failed message doesn't hit a provider
203
+ // that's already failing or rate limiting again the same second. The queue
204
+ // owns retries (the consumer seam says so), so it owns the backoff too.
205
+ p(` "retry_delay": ${config.queues?.retryDelay ?? ASTROID_QUEUE_RETRY_DELAY},`);
177
206
  p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
178
207
  p(" },");
179
208
  p(" ],");
@@ -233,6 +262,11 @@ export function generateAstroidWrangler(config) {
233
262
  p(` "SITE_URL": ${JSON.stringify(primaryHost ? `https://${primaryHost}` : `https://${key}.workers.dev`)},`);
234
263
  p(" // The editor allowlist / owner. Wire this into your auth seam (src/auth.ts).");
235
264
  p(' "OWNER_EMAIL": "",');
265
+ p(" // AI Gateway for the editor's AI assists: request logs, latency and error");
266
+ p(" // rates, and caching. Empty calls Workers AI directly. Create a gateway,");
267
+ p(" // put its id here, and first say on the privacy page that its log holds");
268
+ p(" // the text editors send to the assists.");
269
+ p(` "${ASTROID_AI_GATEWAY_VAR}": "",`);
236
270
  p(" // Edge caching for published pages (ADR 0004). OFF by default, and the");
237
271
  p(" // default is the safe state: with it off every render is `no-store` and");
238
272
  p(" // the Worker cache layer stores nothing.");
@@ -5,3 +5,5 @@ export * from "./seed.js";
5
5
  export * from "./previews.js";
6
6
  export * from "./release.js";
7
7
  export * from "./provision.js";
8
+ export * from "./ship.js";
9
+ export * from "./build-output.js";
@@ -9,3 +9,5 @@ export * from "./seed.js";
9
9
  export * from "./previews.js";
10
10
  export * from "./release.js";
11
11
  export * from "./provision.js";
12
+ export * from "./ship.js";
13
+ export * from "./build-output.js";
@@ -19,6 +19,23 @@ export interface ScaffoldFile {
19
19
  */
20
20
  marker?: string;
21
21
  }
22
+ /**
23
+ * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
24
+ * old URL working (louise-toolkit's `pageRedirects`). The same DDL drizzle-kit
25
+ * writes for it, with `IF NOT EXISTS`, since a site that already added the
26
+ * table by hand must not fail on it.
27
+ */
28
+ export declare const ASTROID_PAGE_REDIRECTS_MIGRATION: string;
29
+ /**
30
+ * `migrations/0005_media_alt_undecided.sql`: the one-time data migration for
31
+ * the three alt text states. Before louise-toolkit 0.35, an empty alt meant
32
+ * both "not written" and "cleared", so an existing `''` is ambiguous. This
33
+ * makes each one "not written" (NULL), so nothing silently becomes decorative,
34
+ * and the owner marks what is. The statement is louise-toolkit's
35
+ * `MEDIA_ALT_UNDECIDED_SQL("media")`, written out rather than imported so the
36
+ * CLI doesn't load `louise-toolkit/editor` and its drizzle-orm peer.
37
+ */
38
+ export declare const ASTROID_MEDIA_ALT_MIGRATION: string;
22
39
  /**
23
40
  * Every scaffold-once file this config implies.
24
41
  *
@@ -58,6 +58,69 @@ function generateAstroidPagesHooks() {
58
58
  "",
59
59
  ].join("\n");
60
60
  }
61
+ /** `src/status-checks.ts`—the site's own status checks, scaffolded once. */
62
+ function generateAstroidStatusChecks() {
63
+ return [
64
+ "// The site's own checks for the public status route (GET /api/louise/status),",
65
+ "// which an outside probe reads. The generated worker spreads `statusChecks`",
66
+ "// after Astroid's own `d1` and `content` checks. Scaffolded once and yours to",
67
+ "// edit.",
68
+ "//",
69
+ "// A check gets `env` and an abort signal, and returns true, false, or",
70
+ "// `{ ok, ageMs }`. Keep each one cheap: anyone can make it run. Its name is",
71
+ "// in the public response, so don't put anything in one you wouldn't publish.",
72
+ 'import type { StatusCheck } from "louise-toolkit/editor";',
73
+ "",
74
+ "export const statusChecks: Record<string, StatusCheck<CloudflareEnv>> = {",
75
+ " // For example, fail when the daily health scan is over 36 hours old:",
76
+ " //",
77
+ ' // import { ageCheck } from "louise-toolkit/editor";',
78
+ ' // import { readHealthSummary } from "louise-toolkit/health";',
79
+ " //",
80
+ " // healthScan: ageCheck(",
81
+ " // async (env) => (await readHealthSummary(env.RL))?.checkedAt,",
82
+ " // 36 * 60 * 60 * 1000,",
83
+ " // ),",
84
+ "};",
85
+ "",
86
+ ].join("\n");
87
+ }
88
+ /**
89
+ * `migrations/0004_page_redirects.sql`: the table that keeps a renamed page's
90
+ * old URL working (louise-toolkit's `pageRedirects`). The same DDL drizzle-kit
91
+ * writes for it, with `IF NOT EXISTS`, since a site that already added the
92
+ * table by hand must not fail on it.
93
+ */
94
+ export const ASTROID_PAGE_REDIRECTS_MIGRATION = [
95
+ "-- Page redirects: a renamed page's old URL answers a 301 to the new one.",
96
+ "-- pagesRoute and versionsRoute record `/old → /new` when a slug changes, and",
97
+ "-- the middleware's redirectFor serves them. Scaffolded by astroidjs.",
98
+ "CREATE TABLE IF NOT EXISTS `page_redirects` (",
99
+ "\t`from_path` text PRIMARY KEY NOT NULL,",
100
+ "\t`to_path` text NOT NULL,",
101
+ "\t`code` integer DEFAULT 301 NOT NULL,",
102
+ "\t`created_at` integer",
103
+ ");",
104
+ "",
105
+ ].join("\n");
106
+ /**
107
+ * `migrations/0005_media_alt_undecided.sql`: the one-time data migration for
108
+ * the three alt text states. Before louise-toolkit 0.35, an empty alt meant
109
+ * both "not written" and "cleared", so an existing `''` is ambiguous. This
110
+ * makes each one "not written" (NULL), so nothing silently becomes decorative,
111
+ * and the owner marks what is. The statement is louise-toolkit's
112
+ * `MEDIA_ALT_UNDECIDED_SQL("media")`, written out rather than imported so the
113
+ * CLI doesn't load `louise-toolkit/editor` and its drizzle-orm peer.
114
+ */
115
+ export const ASTROID_MEDIA_ALT_MIGRATION = [
116
+ "-- Alt text has three states: NULL is not written yet, '' is decorative (an",
117
+ "-- image the owner marked for screen readers to skip), and anything else is",
118
+ "-- the description. An existing '' predates the decorative state, so it's",
119
+ '-- ambiguous; this makes each one "not written". It runs once, before the',
120
+ "-- health scan starts counting only NULL as missing. Scaffolded by astroidjs.",
121
+ 'UPDATE "media" SET "alt" = NULL WHERE "alt" = \'\';',
122
+ "",
123
+ ].join("\n");
61
124
  /** `src/settings-hooks.ts`—the site's settings sanitizers, scaffolded once. */
62
125
  function generateAstroidSettingsHooks() {
63
126
  return [
@@ -99,6 +162,12 @@ export function generateAstroidScaffoldFiles(config) {
99
162
  const catalogSql = generateCatalogMigrationSql(config);
100
163
  if (catalogSql)
101
164
  files.push({ path: "migrations/0003_catalog.sql", contents: catalogSql });
165
+ // --- louise-toolkit 0.35's two schema changes -----------------------------
166
+ // Numbered after the catalog's 0003, and written into an existing site by
167
+ // `astroid generate` because a missing scaffold file is always written. The
168
+ // alt update is a no-op on a fresh database. Wrangler tracks migrations by
169
+ // filename, so a site that already has its own 0004 keeps both.
170
+ files.push({ path: "migrations/0004_page_redirects.sql", contents: ASTROID_PAGE_REDIRECTS_MIGRATION }, { path: "migrations/0005_media_alt_undecided.sql", contents: ASTROID_MEDIA_ALT_MIGRATION });
102
171
  // --- the CWV beacon -------------------------------------------------------
103
172
  // A static file under public/, so it is same-origin and covered by
104
173
  // `script-src 'self'`—an inline script carrying generated content could not
@@ -126,6 +195,10 @@ export function generateAstroidScaffoldFiles(config) {
126
195
  if (config.pages?.hooks) {
127
196
  files.push({ path: "src/pages-hooks.ts", contents: generateAstroidPagesHooks() });
128
197
  }
198
+ // --- status: the site's own probe checks -----------------------------------
199
+ if (config.status?.checks) {
200
+ files.push({ path: "src/status-checks.ts", contents: generateAstroidStatusChecks() });
201
+ }
129
202
  // --- settings: the sanitize + read seam ------------------------------------
130
203
  // Only when asked for (settings.hooks): the generated worker and the Actions
131
204
  // surface both import it, so it must exist whenever either does. Scaffolded
@@ -0,0 +1,42 @@
1
+ import type { AstroidConfig } from "../config.js";
2
+ /** Whether this app applies D1 migrations when it deploys. Default `true`. */
3
+ export declare function astroidRunsMigrations(config: AstroidConfig): boolean;
4
+ /** One step of a ship, in order. */
5
+ export type ShipStep =
6
+ /** Run `wrangler` with these arguments; a non-zero exit stops the ship. */
7
+ {
8
+ run: string[];
9
+ }
10
+ /** Print a line. */
11
+ | {
12
+ note: string;
13
+ }
14
+ /** Write a file, relative to the project root, before the next step. */
15
+ | {
16
+ write: {
17
+ path: string;
18
+ contents: string;
19
+ };
20
+ };
21
+ /** Where `ship preview` writes the config that names the staging database. */
22
+ export declare const ASTROID_PREVIEW_MIGRATIONS_CONFIG = ".wrangler/astroid-preview-migrations.jsonc";
23
+ /** Printed in place of the migrations step when `deploy.migrations` is `false`. */
24
+ export declare const ASTROID_SKIP_MIGRATIONS_NOTE = "(deploy.migrations is false, so this app applies no migrations: another app owns this database's schema)";
25
+ interface ShipContext {
26
+ /** The project's `wrangler.jsonc` text. */
27
+ wrangler: string;
28
+ /** The project root's absolute path. */
29
+ root: string;
30
+ /** Workers Builds' `WORKERS_CI_BRANCH`, which names the Preview. */
31
+ branch?: string;
32
+ }
33
+ /** The steps `astroid ship <target>` runs for a project. */
34
+ export declare function astroidShipPlan(target: "production" | "preview", config: AstroidConfig, { wrangler, root, branch }: ShipContext): ShipStep[];
35
+ /**
36
+ * `astroid doctor`'s check on who migrates this app's database. Returns an
37
+ * error when `deploy.migrations` is `false` but `wrangler.jsonc` still names a
38
+ * `migrations_dir` for `DB`, since that contradiction means someone expects
39
+ * this app to migrate. `null` when there's nothing to report.
40
+ */
41
+ export declare function migrationsOwnershipError(config: AstroidConfig, wrangler: string): string | null;
42
+ export {};