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.
@@ -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";
@@ -6,21 +6,72 @@ export interface ProvisionStep {
6
6
  args: string[];
7
7
  placeholder?: string;
8
8
  }
9
- /** A Secrets Store secret the config binds, which only a person can set. */
9
+ /**
10
+ * The value provision gives a staging secret it creates: `random` is a fresh
11
+ * random value for each site, and `turnstile-test` is
12
+ * {@link TURNSTILE_TEST_SECRET}.
13
+ */
14
+ export type StagingSecretValue = "random" | "turnstile-test";
15
+ /**
16
+ * The staging secrets provision creates, by the binding name in the `previews`
17
+ * block. Nothing else is created: a secret with any other binding, and every
18
+ * production secret, is left for a person.
19
+ */
20
+ export declare const ASTROID_STAGING_SECRET_VALUES: Readonly<Record<string, StagingSecretValue>>;
21
+ /**
22
+ * Cloudflare's Turnstile test secret key, which passes every token. Staging
23
+ * uses it so a Preview's forms and sign-in work without a real widget.
24
+ */
25
+ export declare const TURNSTILE_TEST_SECRET = "1x0000000000000000000000000000000AA";
26
+ /** A Secrets Store secret the config binds. */
10
27
  export interface ProvisionSecret {
11
28
  binding: string;
12
29
  storeId: string;
13
30
  secretName: string;
14
31
  /** `production` for a top-level binding, `staging` for one in `previews`. */
15
32
  environment: "production" | "staging";
33
+ /**
34
+ * Set when provision creates the secret itself, and says what value it gets.
35
+ * Absent means only a person can set it.
36
+ */
37
+ create?: StagingSecretValue;
16
38
  }
17
39
  export interface ProvisionPlan {
18
40
  steps: ProvisionStep[];
19
41
  secrets: ProvisionSecret[];
20
42
  /** Whether `account_id` is set, so wrangler doesn't have to pick one. */
21
43
  hasAccount: boolean;
44
+ /**
45
+ * The `account_id` from `wrangler.jsonc`, when it's set. Wrangler prefers it
46
+ * to `CLOUDFLARE_ACCOUNT_ID`, so every command provision runs uses it.
47
+ */
48
+ accountId?: string;
22
49
  }
23
50
  /** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
24
51
  export declare function provisionPlan(text: string): ProvisionPlan;
25
52
  /** Put a created resource's ID in place of its placeholder, everywhere. */
26
53
  export declare function applyProvisionedId(text: string, placeholder: string, id: string): string;
54
+ /** What provision does with a staging secret it creates itself. */
55
+ export interface StagingSecretStep {
56
+ secret: ProvisionSecret & {
57
+ create: StagingSecretValue;
58
+ };
59
+ /**
60
+ * `create` when the store doesn't have it, and `exists` when it does, so a
61
+ * re-run leaves it alone. `unknown` when the store couldn't be listed, so
62
+ * provision can't tell and creates nothing.
63
+ */
64
+ status: "create" | "exists" | "unknown";
65
+ }
66
+ /**
67
+ * Decide, for each staging secret provision creates, whether it still needs
68
+ * creating. `existing` maps a store ID to the secret names already in it; a
69
+ * store missing from the map couldn't be listed.
70
+ */
71
+ export declare function stagingSecretSteps(secrets: readonly ProvisionSecret[], existing: ReadonlyMap<string, ReadonlySet<string>>): StagingSecretStep[];
72
+ /**
73
+ * The secret names in the output of `wrangler secrets-store secret list`.
74
+ * Wrangler prints a table rather than JSON, with the name in the first column,
75
+ * so this reads the first cell of each row and drops the header.
76
+ */
77
+ export declare function secretNamesFromList(output: string): string[];
@@ -10,11 +10,35 @@
10
10
  // Buckets carry a name instead of an ID, so every bucket the file names is
11
11
  // created, and one that already exists is fine.
12
12
  //
13
+ // Two staging secrets need no person, so the plan marks them for provision to
14
+ // create: the session secret, which can be any random value as long as it isn't
15
+ // production's, and the Turnstile secret, which is Cloudflare's test secret that
16
+ // always passes. Every other secret, and every production one, still needs a
17
+ // person, so the CLI prints those instead.
18
+ //
13
19
  // Pure: text in, plan out, so it's tested directly and the CLI only runs it.
14
20
  import { parseJsonc } from "./previews.js";
21
+ /**
22
+ * The staging secrets provision creates, by the binding name in the `previews`
23
+ * block. Nothing else is created: a secret with any other binding, and every
24
+ * production secret, is left for a person.
25
+ */
26
+ export const ASTROID_STAGING_SECRET_VALUES = {
27
+ SESSION_SECRET: "random",
28
+ // deepcode ignore HardcodedNonCryptoSecret: A value kind, not a credential.
29
+ TURNSTILE_SECRET: "turnstile-test",
30
+ };
31
+ /**
32
+ * Cloudflare's Turnstile test secret key, which passes every token. Staging
33
+ * uses it so a Preview's forms and sign-in work without a real widget.
34
+ */
35
+ // deepcode ignore HardcodedNonCryptoSecret: Cloudflare's public Turnstile test secret, not a credential.
36
+ export const TURNSTILE_TEST_SECRET = "1x0000000000000000000000000000000AA";
15
37
  const PLACEHOLDER = /<run:\s*wrangler\s+(d1\s+create|kv\s+namespace\s+create)\s+([^\s>]+)\s*>/g;
16
38
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
17
39
  const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
40
+ /** Whether a config value is filled in, rather than empty or a `<...>` placeholder. */
41
+ const isReal = (v) => v.length > 0 && !v.startsWith("<");
18
42
  /** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
19
43
  export function provisionPlan(text) {
20
44
  const steps = [];
@@ -44,22 +68,68 @@ export function provisionPlan(text) {
44
68
  ["staging", previews],
45
69
  ]) {
46
70
  for (const s of list(section.secrets_store_secrets)) {
47
- secrets.push({
71
+ const secret = {
48
72
  binding: String(s.binding ?? ""),
49
73
  storeId: String(s.store_id ?? ""),
50
74
  secretName: String(s.secret_name ?? ""),
51
75
  environment,
52
- });
76
+ };
77
+ const create = Object.hasOwn(ASTROID_STAGING_SECRET_VALUES, secret.binding)
78
+ ? ASTROID_STAGING_SECRET_VALUES[secret.binding]
79
+ : undefined;
80
+ // Only staging, and only against a real store and name: a placeholder
81
+ // can't be created against, so it's left for a person.
82
+ if (environment === "staging" &&
83
+ create &&
84
+ isReal(secret.storeId) &&
85
+ isReal(secret.secretName)) {
86
+ secret.create = create;
87
+ }
88
+ secrets.push(secret);
53
89
  }
54
90
  }
55
91
  const account = top.account_id;
56
- return {
57
- steps,
58
- secrets,
59
- hasAccount: typeof account === "string" && account.length > 0 && !account.startsWith("<"),
60
- };
92
+ const hasAccount = typeof account === "string" && isReal(account);
93
+ return { steps, secrets, hasAccount, ...(hasAccount ? { accountId: account } : {}) };
61
94
  }
62
95
  /** Put a created resource's ID in place of its placeholder, everywhere. */
63
96
  export function applyProvisionedId(text, placeholder, id) {
64
97
  return text.split(placeholder).join(id);
65
98
  }
99
+ /**
100
+ * Decide, for each staging secret provision creates, whether it still needs
101
+ * creating. `existing` maps a store ID to the secret names already in it; a
102
+ * store missing from the map couldn't be listed.
103
+ */
104
+ export function stagingSecretSteps(secrets, existing) {
105
+ const steps = [];
106
+ for (const secret of secrets) {
107
+ if (!secret.create)
108
+ continue;
109
+ const names = existing.get(secret.storeId);
110
+ steps.push({
111
+ secret: { ...secret, create: secret.create },
112
+ status: !names ? "unknown" : names.has(secret.secretName) ? "exists" : "create",
113
+ });
114
+ }
115
+ return steps;
116
+ }
117
+ // Wrangler colors the table on a terminal: an escape, then `[`, digits, and `m`.
118
+ const ANSI_COLOR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
119
+ /**
120
+ * The secret names in the output of `wrangler secrets-store secret list`.
121
+ * Wrangler prints a table rather than JSON, with the name in the first column,
122
+ * so this reads the first cell of each row and drops the header.
123
+ */
124
+ export function secretNamesFromList(output) {
125
+ const names = [];
126
+ for (const line of output.replace(ANSI_COLOR, "").split("\n")) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed.startsWith("│"))
129
+ continue;
130
+ const name = trimmed.split("│")[1]?.trim();
131
+ if (name && name !== "Name")
132
+ names.push(name);
133
+ }
134
+ return names;
135
+ }
@@ -2,5 +2,11 @@
2
2
  export declare const ASTROID_DEPLOY_BRANCH = "deploy/production";
3
3
  /** Where the workflow lives, relative to the repository root. */
4
4
  export declare const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
5
+ /** The Actions variable that holds the release app's App ID. */
6
+ export declare const ASTROID_RELEASE_APP_ID_VAR = "RELEASE_APP_ID";
7
+ /** The Actions secret that holds the release app's private key. */
8
+ export declare const ASTROID_RELEASE_APP_KEY_SECRET = "RELEASE_APP_PRIVATE_KEY";
9
+ /** Where the one-time release setup is written down. */
10
+ export declare const ASTROID_RELEASE_SETUP_URL = "https://docs.astroidjs.org/guide/releases/";
5
11
  /** The release workflow's contents. Pure, and the same for every site. */
6
12
  export declare function generateAstroidReleaseWorkflow(): string;
@@ -10,6 +10,12 @@
10
10
  // Nobody commits to the branch; it's a tag carried as a branch, and GitHub never
11
11
  // holds a Cloudflare credential.
12
12
  //
13
+ // The push uses a GitHub App's token, not the workflow's own `GITHUB_TOKEN`. A
14
+ // repository ruleset keeps everyone else off `deploy/production`, and GitHub
15
+ // rejects the GitHub Actions app as a ruleset bypass actor, so a ruleset that
16
+ // guards the branch blocks `GITHUB_TOKEN` too. A GitHub App can be the bypass
17
+ // actor. The setup is in the docs site's Releases guide.
18
+ //
13
19
  // A regenerated file, like the worker trio: `astroid generate` rewrites it and
14
20
  // `astroid doctor` fails when it drifts, so a hand edit can't quietly change
15
21
  // which commits reach production.
@@ -17,8 +23,16 @@
17
23
  export const ASTROID_DEPLOY_BRANCH = "deploy/production";
18
24
  /** Where the workflow lives, relative to the repository root. */
19
25
  export const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
26
+ /** The Actions variable that holds the release app's App ID. */
27
+ export const ASTROID_RELEASE_APP_ID_VAR = "RELEASE_APP_ID";
28
+ /** The Actions secret that holds the release app's private key. */
29
+ export const ASTROID_RELEASE_APP_KEY_SECRET = "RELEASE_APP_PRIVATE_KEY";
30
+ /** Where the one-time release setup is written down. */
31
+ export const ASTROID_RELEASE_SETUP_URL = "https://docs.astroidjs.org/guide/releases/";
20
32
  /** The release workflow's contents. Pure, and the same for every site. */
21
33
  export function generateAstroidReleaseWorkflow() {
34
+ const appId = ASTROID_RELEASE_APP_ID_VAR;
35
+ const appKey = ASTROID_RELEASE_APP_KEY_SECRET;
22
36
  return `# Generated by astroid. Don't edit: \`astroid generate\` rewrites this file and
23
37
  # \`astroid doctor\` fails when it drifts.
24
38
  #
@@ -26,8 +40,14 @@ export function generateAstroidReleaseWorkflow() {
26
40
  # release/<version> branch when a released version needs a patch. This moves
27
41
  # ${ASTROID_DEPLOY_BRANCH} to the tagged commit, and Workers Builds deploys it to
28
42
  # production. Nobody commits to ${ASTROID_DEPLOY_BRANCH}; a repository ruleset lets
29
- # only this workflow update it. To roll back, tag the earlier commit with the
30
- # next patch version.
43
+ # only the release GitHub App update it. To roll back, tag the earlier commit
44
+ # with the next patch version.
45
+ #
46
+ # The push uses the release app's token, from the ${appId} variable and
47
+ # the ${appKey} secret. The workflow's own GITHUB_TOKEN can't push:
48
+ # GitHub rejects the GitHub Actions app as a ruleset bypass actor, so the
49
+ # ruleset blocks it too. One-time setup, the app and the ruleset:
50
+ # ${ASTROID_RELEASE_SETUP_URL}
31
51
  name: Release
32
52
 
33
53
  on:
@@ -46,9 +66,28 @@ jobs:
46
66
  release:
47
67
  runs-on: ubuntu-latest
48
68
  steps:
69
+ - name: Check the release app
70
+ env:
71
+ APP_ID: \${{ vars.${appId} }}
72
+ APP_KEY: \${{ secrets.${appKey} }}
73
+ run: |
74
+ if [ -z "$APP_ID" ] || [ -z "$APP_KEY" ]; then
75
+ echo "::error::Releasing needs the ${appId} variable and the ${appKey} secret, from the GitHub App that the ${ASTROID_DEPLOY_BRANCH} ruleset lets through. Set them up as ${ASTROID_RELEASE_SETUP_URL} describes."
76
+ exit 1
77
+ fi
78
+
79
+ - name: Mint the release app's token
80
+ id: app-token
81
+ uses: actions/create-github-app-token@v2
82
+ with:
83
+ app-id: \${{ vars.${appId} }}
84
+ private-key: \${{ secrets.${appKey} }}
85
+
49
86
  - uses: actions/checkout@v4
50
87
  with:
51
88
  fetch-depth: 0
89
+ # The push below uses the credentials checkout leaves in place.
90
+ token: \${{ steps.app-token.outputs.token }}
52
91
 
53
92
  - name: Check the tag
54
93
  run: |
@@ -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 {};