astroidjs 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/astroid.mjs CHANGED
@@ -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";
@@ -667,17 +668,42 @@ New project: pnpm create astroid@latest
667
668
  // `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
668
669
  // top level and `previews` alike, and writes each new ID back in place of its
669
670
  // 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.
671
+ // release, and re-running it only creates what's still missing. It creates the
672
+ // staging secrets that need no person (a random SESSION_SECRET and Turnstile's
673
+ // test secret), skipping any the store already has. Every other secret, and
674
+ // dashboard settings, need a person, so it prints those instead.
675
+ //
676
+ // Every wrangler call runs in the site's directory, so wrangler reads the same
677
+ // `wrangler.jsonc` for each and picks the same account: its `account_id`, which
678
+ // wrangler prefers to CLOUDFLARE_ACCOUNT_ID.
672
679
  async function cmdProvision(cwd, rest) {
673
680
  const dryRun = rest.includes("--dry-run");
674
681
  const assumeYes = rest.includes("--yes") || rest.includes("-y");
675
682
  const wranglerPath = join(cwd, "wrangler.jsonc");
676
683
  if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
677
684
 
678
- const { provisionPlan, applyProvisionedId } = await import(GENERATORS_URL);
685
+ const {
686
+ provisionPlan,
687
+ applyProvisionedId,
688
+ stagingSecretSteps,
689
+ secretNamesFromList,
690
+ TURNSTILE_TEST_SECRET,
691
+ } = await import(GENERATORS_URL);
679
692
  let text = readFileSync(wranglerPath, "utf8");
680
693
  const plan = provisionPlan(text);
694
+ const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
695
+
696
+ // Which staging secrets already exist, per store, so a re-run creates nothing
697
+ // twice. Listing is read-only, so a dry run does it too.
698
+ const existing = new Map();
699
+ if (wranglerBin) {
700
+ for (const storeId of new Set(plan.secrets.filter((s) => s.create).map((s) => s.storeId))) {
701
+ const names = listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList);
702
+ if (names) existing.set(storeId, names);
703
+ }
704
+ }
705
+ const secretSteps = stagingSecretSteps(plan.secrets, existing);
706
+ const toCreate = secretSteps.filter((s) => s.status === "create");
681
707
 
682
708
  out("astroid provision — plan:\n");
683
709
  if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
@@ -685,15 +711,42 @@ async function cmdProvision(cwd, rest) {
685
711
  const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
686
712
  out(` wrangler ${s.args.join(" ")}${note}`);
687
713
  }
688
- if (!plan.hasAccount && !process.env.CLOUDFLARE_ACCOUNT_ID) {
714
+ const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
715
+ if (!plan.hasAccount && !envAccount) {
689
716
  out(
690
717
  "\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
691
718
  );
719
+ } else if (plan.accountId && envAccount && envAccount !== plan.accountId) {
720
+ out(
721
+ `\n ! wrangler.jsonc's account_id (${plan.accountId}) wins over ` +
722
+ `CLOUDFLARE_ACCOUNT_ID (${envAccount}), so provision uses ${plan.accountId}.`,
723
+ );
724
+ }
725
+ if (secretSteps.length > 0) {
726
+ out("\nStaging secrets it creates itself:");
727
+ for (const { secret, status } of secretSteps) {
728
+ const what =
729
+ status === "create"
730
+ ? `will create, with ${secret.create === "random" ? "a new random value" : "Turnstile's always-passes test secret"}`
731
+ : status === "exists"
732
+ ? "exists, so it's left alone"
733
+ : wranglerBin
734
+ ? "couldn't list the store, so it's left for you"
735
+ : "can't check without wrangler, so it's left for you";
736
+ out(` [staging] ${secret.secretName} in store ${secret.storeId}: ${what}`);
737
+ }
692
738
  }
693
- const printSecrets = () => {
694
- if (plan.secrets.length === 0) return;
739
+ // The secrets a person still sets: every one provision doesn't create, and
740
+ // any it meant to but couldn't.
741
+ const printSecrets = (failed = []) => {
742
+ const byHand = [
743
+ ...plan.secrets.filter((s) => !s.create),
744
+ ...secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret),
745
+ ...failed,
746
+ ];
747
+ if (byHand.length === 0) return;
695
748
  out("\nSecrets Store secrets it binds; create any that don't exist yet:");
696
- for (const s of plan.secrets) {
749
+ for (const s of byHand) {
697
750
  out(
698
751
  ` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
699
752
  `--name ${s.secretName} --scopes workers --remote`,
@@ -706,7 +759,7 @@ async function cmdProvision(cwd, rest) {
706
759
  out("\n(dry run — nothing created)");
707
760
  return;
708
761
  }
709
- if (plan.steps.length > 0 && !assumeYes) {
762
+ if ((plan.steps.length > 0 || toCreate.length > 0) && !assumeYes) {
710
763
  if (!process.stdin.isTTY)
711
764
  fail("Refusing to create resources non-interactively. Re-run with --yes.");
712
765
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -718,7 +771,6 @@ async function cmdProvision(cwd, rest) {
718
771
  }
719
772
  }
720
773
 
721
- const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
722
774
  if (!wranglerBin) fail("Could not find `wrangler` in this project.");
723
775
  for (const s of plan.steps) {
724
776
  out(`\n▸ wrangler ${s.args.join(" ")}`);
@@ -745,10 +797,81 @@ async function cmdProvision(cwd, rest) {
745
797
  writeFileSync(wranglerPath, text);
746
798
  out(` ↳ ${s.name} = ${id}`);
747
799
  }
748
- printSecrets();
800
+
801
+ // The value goes to wrangler as an argument of a direct spawn, never through
802
+ // a shell, and is never printed: wrangler shows it as REDACTED.
803
+ const failed = [];
804
+ for (const { secret } of toCreate) {
805
+ const args = [
806
+ "secrets-store",
807
+ "secret",
808
+ "create",
809
+ secret.storeId,
810
+ "--name",
811
+ secret.secretName,
812
+ "--scopes",
813
+ "workers",
814
+ "--remote",
815
+ ];
816
+ out(`\n▸ wrangler ${args.join(" ")} --value <${secret.create}>`);
817
+ const value =
818
+ secret.create === "random" ? randomBytes(48).toString("base64") : TURNSTILE_TEST_SECRET;
819
+ const res = spawnSync(process.execPath, [wranglerBin, ...args, "--value", value], {
820
+ cwd,
821
+ stdio: "inherit",
822
+ });
823
+ if (res.status !== 0) failed.push(secret);
824
+ }
825
+
826
+ printSecrets(failed);
827
+ const unlisted = [
828
+ ...new Set(secretSteps.filter((s) => s.status === "unknown").map((s) => s.secret.storeId)),
829
+ ];
830
+ if (unlisted.length > 0 || failed.length > 0) {
831
+ if (unlisted.length > 0) {
832
+ out(
833
+ `\n✘ Couldn't list Secrets Store ${unlisted.join(", ")}, so its staging secrets ` +
834
+ "weren't created. Check that the store is in this account, then re-run.",
835
+ );
836
+ }
837
+ if (failed.length > 0) {
838
+ out(`\n✘ Couldn't create ${failed.map((s) => s.secretName).join(", ")}. Re-run to retry.`);
839
+ }
840
+ process.exitCode = 1;
841
+ return;
842
+ }
749
843
  out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
750
844
  }
751
845
 
846
+ /**
847
+ * The secret names in a Secrets Store, or null when it can't be listed. Wrangler
848
+ * prints a table, a page at a time, and fails on a page with no secrets, which
849
+ * is how an empty store, or the page after the last one, reads.
850
+ */
851
+ function listStoreSecrets(wranglerBin, cwd, storeId, secretNamesFromList) {
852
+ const PER_PAGE = 100; // The API's maximum.
853
+ const names = new Set();
854
+ for (let page = 1; page <= 50; page++) {
855
+ const res = spawnSync(
856
+ process.execPath,
857
+ [
858
+ wranglerBin,
859
+ ...["secrets-store", "secret", "list", storeId, "--remote"],
860
+ ...["--per-page", String(PER_PAGE), "--page", String(page)],
861
+ ],
862
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
863
+ );
864
+ if (res.status !== 0) {
865
+ return `${res.stdout}${res.stderr}`.includes("returned no secrets") ? names : null;
866
+ }
867
+ const found = secretNamesFromList(res.stdout);
868
+ const before = names.size;
869
+ for (const name of found) names.add(name);
870
+ if (found.length < PER_PAGE || names.size === before) break;
871
+ }
872
+ return names;
873
+ }
874
+
752
875
  // `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
753
876
  // repository instead of a dashboard field. An account move once rewrote a
754
877
  // site's dashboard deploy command to a bare `wrangler deploy`, and migrations
@@ -50,10 +50,10 @@ export function assertAuthIsolation(config) {
50
50
  return;
51
51
  if (!portal.cookiePrefix || portal.cookiePrefix === ASTROID_EDITOR_COOKIE_PREFIX) {
52
52
  throw new AstroidConfigError(`portal.cookiePrefix must be set and distinct from the editor's (${JSON.stringify(ASTROID_EDITOR_COOKIE_PREFIX)}). Two instances sharing a cookie prefix silently sign you out of one when ` +
53
- 'you sign into the other. Pick a project-specific prefix (e.g. "acme_shop").');
53
+ 'you sign into the other. Pick a project-specific prefix (for example, "acme_shop").');
54
54
  }
55
55
  if (portal.tablePrefix === ASTROID_EDITOR_TABLE_PREFIX) {
56
- throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}) — a shared table prefix merges the two instances into one user table. ` +
56
+ throw new AstroidConfigError(`portal.tablePrefix must differ from the editor's (${JSON.stringify(ASTROID_EDITOR_TABLE_PREFIX)}): a shared table prefix merges the two instances into one user table. ` +
57
57
  "Leave it unset (the unprefixed `user`/`session` tables) or use a distinct prefix.");
58
58
  }
59
59
  }
@@ -109,7 +109,7 @@ export function assertCommerceRoles(commerce) {
109
109
  invoicing: "invoicing",
110
110
  pos: "locations/inventory",
111
111
  }[role];
112
- throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role — its louise-toolkit client has no ${missing} API. Providers that can: ${able.join(", ")}.`);
112
+ throw new AstroidConfigError(`commerce: ${provider} can't serve the "${role}" role, because its louise-toolkit client has no ${missing} API. Providers that can: ${able.join(", ")}.`);
113
113
  }
114
114
  };
115
115
  // The shorthand assigns itself to a role it can serve, so it only has to be a
@@ -145,7 +145,8 @@ export async function astroidCatalogSync(items, options) {
145
145
  }
146
146
  }
147
147
  if (items.length > 0 && result.failed === items.length) {
148
- throw new AstroidUsageError(`Catalog sync failed for all ${items.length} item(s) — nothing was written. ` +
148
+ const scope = items.length === 1 ? "the only item" : `all ${items.length} items`;
149
+ throw new AstroidUsageError(`Catalog sync failed for ${scope}, so nothing was written. ` +
149
150
  `First error: ${result.errors[0]?.message ?? "unknown"}. ` +
150
151
  "This is usually an unapplied migration (the catalog table doesn't exist yet) " +
151
152
  "or an unavailable D1 binding.");
package/dist/config.js CHANGED
@@ -102,7 +102,7 @@ function assertCrons(config) {
102
102
  }
103
103
  const owner = seen.get(expression);
104
104
  if (owner) {
105
- throw new AstroidConfigError(`Duplicate cron \`${expression}\` — it already belongs to ${owner}. One \`scheduled\` ` +
105
+ throw new AstroidConfigError(`Duplicate cron \`${expression}\`: it already belongs to ${owner}. One \`scheduled\` ` +
106
106
  "handler dispatches on the expression, so the first branch wins and this one would " +
107
107
  "never run. Use a different minute.");
108
108
  }
@@ -123,10 +123,10 @@ function assertTenancy(config) {
123
123
  return;
124
124
  const pattern = tenancy.hostPattern?.trim();
125
125
  if (!pattern) {
126
- throw new AstroidConfigError('`tenancy.hostPattern` is required, e.g. `"*.example.com"`');
126
+ throw new AstroidConfigError('`tenancy.hostPattern` is required: a wildcard such as `"*.example.com"`');
127
127
  }
128
128
  if (!pattern.startsWith("*.")) {
129
- throw new AstroidConfigError(`\`tenancy.hostPattern\` must be a wildcard starting with "*." — got "${pattern}". ` +
129
+ throw new AstroidConfigError(`\`tenancy.hostPattern\` must be a wildcard starting with "*.", but it's "${pattern}". ` +
130
130
  "A fixed host is a custom domain: put it in `hosts` instead.");
131
131
  }
132
132
  const apex = pattern.slice(2);
@@ -145,17 +145,17 @@ function assertTenancy(config) {
145
145
  // that renders the wrong page, with nothing pointing back at the config.
146
146
  for (const [label, prefix] of Object.entries(tenancy.apps ?? {})) {
147
147
  if (!label || label.includes(".")) {
148
- throw new AstroidConfigError(`\`tenancy.apps\` label "${label}" must be a single subdomain label — ` +
149
- "Cloudflare's wildcard matches one level.");
148
+ throw new AstroidConfigError(`\`tenancy.apps\` label "${label}" must be a single subdomain label, ` +
149
+ "because Cloudflare's wildcard matches one level.");
150
150
  }
151
151
  if ((tenancy.reserved ?? []).includes(label)) {
152
152
  throw new AstroidConfigError(`"${label}" is in both \`tenancy.apps\` and \`tenancy.reserved\`. ` +
153
- "An app label is implicitly reserved — keep it in `apps` only, or the " +
153
+ "An app label is implicitly reserved, so keep it in `apps` only, or the " +
154
154
  "two lists drift and `reserved` silently wins.");
155
155
  }
156
156
  if (!prefix.startsWith("/") || prefix === "/" || prefix.endsWith("/")) {
157
157
  throw new AstroidConfigError(`\`tenancy.apps.${label}\` must be an internal path prefix like "/studio" ` +
158
- `(leading slash, no trailing slash, not "/") — got "${prefix}". ` +
158
+ `(leading slash, no trailing slash, not "/"), but it's "${prefix}". ` +
159
159
  'The rewrite is `prefix + pathname`, so "/" or a trailing slash produces "//…".');
160
160
  }
161
161
  }
@@ -212,9 +212,9 @@ export function defineAstroid(config) {
212
212
  assertCrons(config);
213
213
  assertTenancy(config);
214
214
  if (config.portal?.gated) {
215
- throw new AstroidConfigError("`portal.gated` is not implemented — it is accepted but wires no guard, so the site " +
215
+ throw new AstroidConfigError("`portal.gated` is not implemented: it is accepted but wires no guard, so the site " +
216
216
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
217
- 'listing the prefixes you mean in `portal.routes` (e.g. `[{ prefix: "/" }]` with your ' +
217
+ 'listing the prefixes you mean in `portal.routes` (for example, `[{ prefix: "/" }]` with your ' +
218
218
  "login and auth paths ahead of it).");
219
219
  }
220
220
  return config;
@@ -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: |
@@ -68,6 +68,7 @@ export function generateAstroidWorker(config) {
68
68
  const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
69
69
  const editorImports = [
70
70
  "DEFAULT_PAGE_FIELDS",
71
+ "type PagesWrite",
71
72
  ...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
72
73
  ].sort();
73
74
  const tables = [
@@ -104,8 +105,8 @@ export function generateAstroidWorker(config) {
104
105
  //
105
106
  // `versionsTable` makes a DELETE remove the page's version snapshots,
106
107
  // which have no foreign key to the page and would otherwise orphan.
107
- // `afterWrite` rebuilds the search index, which plain CRUD writes leave
108
- // stale.
108
+ // `afterWrite` syncs the written page's search index entry, which plain
109
+ // CRUD writes leave stale.
109
110
  return 'pagesRoute({ table: pages, versionsTable: pagesVersions, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
110
111
  case "save":
111
112
  // No `bufferKv` here, deliberately: `saveRoute` has no such option. It
@@ -175,7 +176,7 @@ export function generateAstroidWorker(config) {
175
176
  p('import { env } from "cloudflare:workers";');
176
177
  p('import { handle } from "@astrojs/cloudflare/handler";');
177
178
  p('import type { EditorSession } from "louise-toolkit/auth";');
178
- p('import { createLocalApi } from "louise-toolkit/content";');
179
+ p('import { reindexDoc } from "louise-toolkit/content";');
179
180
  p(inquiries
180
181
  ? 'import { db, inquiriesForm } from "louise-toolkit/db";'
181
182
  : 'import { db } from "louise-toolkit/db";');
@@ -251,10 +252,12 @@ export function generateAstroidWorker(config) {
251
252
  : "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
252
253
  p();
253
254
  p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
254
- p("// title or slug change until something rebuilds it. Best-effort: pagesRoute");
255
- p("// swallows a throw here, so a stale index never fails the write itself.");
256
- p("async function reindexPagesSearch(editor: EditorSession): Promise<void> {");
257
- p(" await createLocalApi(db(env.DB), pages, pagesCollection).reindexSearch({ session: editor });");
255
+ p("// title or slug change until something syncs it. This syncs only the page the");
256
+ p("// write touched, rather than rebuilding the whole index on every save. After a");
257
+ p("// delete the row is gone, and reindexDoc removes its entry. Best-effort:");
258
+ p("// pagesRoute swallows a throw here, so a stale index never fails the write.");
259
+ p("async function reindexPagesSearch(_editor: EditorSession, { id }: PagesWrite): Promise<void> {");
260
+ p(" await reindexDoc(db(env.DB), pages, pagesCollection, id);");
258
261
  p("}");
259
262
  p();
260
263
  p("// Editable site_settings columns the Settings panel may write, and which of");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -64,14 +64,14 @@
64
64
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
65
65
  "@vitest/coverage-v8": "4.1.11",
66
66
  "astro": "^7.2.9",
67
- "louise-toolkit": "^0.33.0",
67
+ "louise-toolkit": "^0.34.0",
68
68
  "solid-js": "^1.9.15",
69
69
  "typescript": "^6.0.3",
70
70
  "vitest": "^4.1.11"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "astro": "^7.0.9",
74
- "louise-toolkit": "^0.33.0",
74
+ "louise-toolkit": "^0.34.0",
75
75
  "solid-js": "^1.9.0"
76
76
  },
77
77
  "peerDependenciesMeta": {