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 +133 -10
- package/dist/auth/index.js +2 -2
- package/dist/commerce/roles.js +1 -1
- package/dist/commerce/sync.js +2 -1
- package/dist/config.js +9 -9
- package/dist/project/provision.d.ts +52 -1
- package/dist/project/provision.js +77 -7
- package/dist/project/release.d.ts +6 -0
- package/dist/project/release.js +41 -2
- package/dist/worker/generate.js +10 -7
- package/package.json +3 -3
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.
|
|
671
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/auth/index.js
CHANGED
|
@@ -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 (
|
|
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)})
|
|
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
|
}
|
package/dist/commerce/roles.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/commerce/sync.js
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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
|
|
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 "*."
|
|
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
|
|
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 "/")
|
|
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
|
|
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` (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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;
|
package/dist/project/release.js
CHANGED
|
@@ -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
|
|
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: |
|
package/dist/worker/generate.js
CHANGED
|
@@ -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`
|
|
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 {
|
|
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
|
|
255
|
-
p("//
|
|
256
|
-
p("
|
|
257
|
-
p("
|
|
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.
|
|
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.
|
|
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.
|
|
74
|
+
"louise-toolkit": "^0.34.0",
|
|
75
75
|
"solid-js": "^1.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependenciesMeta": {
|