astroidjs 0.15.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/README.md +7 -5
- package/bin/astroid.mjs +357 -5
- 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/portfolio/scaffold.js +5 -1
- package/dist/project/actions.js +6 -1
- package/dist/project/generate.js +4 -3
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/previews.d.ts +18 -0
- package/dist/project/previews.js +215 -0
- package/dist/project/provision.d.ts +77 -0
- package/dist/project/provision.js +135 -0
- package/dist/project/release.d.ts +12 -0
- package/dist/project/release.js +118 -0
- package/dist/queues/scaffold.js +5 -0
- package/dist/queues/webhook.d.ts +16 -0
- package/dist/queues/webhook.js +20 -3
- package/dist/schema/collections.js +9 -8
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/media-base.d.ts +13 -0
- package/dist/schema/media-base.js +33 -0
- package/dist/worker/generate.js +44 -18
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -339,11 +339,13 @@ Astro app—in one step.
|
|
|
339
339
|
|
|
340
340
|
## Roadmap
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
Every item on the first roadmap has shipped:
|
|
343
|
+
|
|
344
|
+
1. **Config surface** (`defineAstroid`)—single brand per project.
|
|
345
|
+
2. Config → generated Drizzle schema.
|
|
346
|
+
3. Config → generated `worker.ts` + middleware (no hand-wired route ordering).
|
|
347
|
+
4. `<Section>` / `<Editable>` / `<Collection>` component primitives.
|
|
348
|
+
5. **CLI**—`astroid generate / doctor / dev / build / deploy`; `create-astroid`
|
|
347
349
|
scaffold (`pnpm create astroid`).
|
|
348
350
|
|
|
349
351
|
## License
|
package/bin/astroid.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// astroid dev [...astro args] generate, then `astro dev`
|
|
9
9
|
// astroid build [...astro args] generate, then `astro build`
|
|
10
10
|
// astroid deploy [--dry-run] [--yes] [--local] provision + migrate + secrets + deploy
|
|
11
|
+
// astroid ship production | preview migrate, then deploy or preview (Workers Builds)
|
|
11
12
|
//
|
|
12
13
|
// It loads the project's `astroid.config.ts` with Node's native TypeScript
|
|
13
14
|
// stripping (the config only imports the built `astroidjs`, so it resolves), and
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
// CLI ships in, no dependency on node_modules layout (mirrors the louise bin).
|
|
16
17
|
|
|
17
18
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
18
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
21
|
import { createRequire } from "node:module";
|
|
20
22
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -23,6 +25,16 @@ import { pathToFileURL } from "node:url";
|
|
|
23
25
|
|
|
24
26
|
const GENERATORS_URL = new URL("../dist/index.js", import.meta.url).href;
|
|
25
27
|
|
|
28
|
+
/** The repository root, where `.github/` lives, or null outside a git checkout.
|
|
29
|
+
* A site's Astroid project can sit below it (`workers/site`). */
|
|
30
|
+
function gitRoot(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
// --- tiny arg parser -------------------------------------------------------
|
|
27
39
|
// Splits at the first non-flag token into { command, flags, rest }. `rest` is
|
|
28
40
|
// everything after the command, preserved verbatim so `dev`/`build` can forward
|
|
@@ -77,7 +89,12 @@ async function loadConfig(cwd, explicit) {
|
|
|
77
89
|
|
|
78
90
|
// --- commands --------------------------------------------------------------
|
|
79
91
|
async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
80
|
-
const {
|
|
92
|
+
const {
|
|
93
|
+
generateAstroidProject,
|
|
94
|
+
generateAstroidScaffoldFiles,
|
|
95
|
+
generateAstroidReleaseWorkflow,
|
|
96
|
+
ASTROID_RELEASE_WORKFLOW_PATH,
|
|
97
|
+
} = await import(GENERATORS_URL);
|
|
81
98
|
const { config } = await loadConfig(cwd, flags.config);
|
|
82
99
|
const files = generateAstroidProject(config);
|
|
83
100
|
for (const file of files) {
|
|
@@ -87,6 +104,16 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
|
87
104
|
if (!quiet) out(` ✓ ${file.path}`);
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
// The release workflow, at the repository root rather than the project,
|
|
108
|
+
// because GitHub reads workflows only from there. Regenerated like the trio.
|
|
109
|
+
const root = gitRoot(cwd);
|
|
110
|
+
if (root) {
|
|
111
|
+
const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
|
|
112
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
113
|
+
writeFileSync(abs, generateAstroidReleaseWorkflow());
|
|
114
|
+
if (!quiet) out(` ✓ ${ASTROID_RELEASE_WORKFLOW_PATH} (repository root)`);
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
// Scaffold-once files for whatever modules the config switched on.
|
|
91
118
|
//
|
|
92
119
|
// Written only when ABSENT—each is a seam the project owns, so overwriting
|
|
@@ -124,8 +151,15 @@ async function cmdGenerate(cwd, flags, { quiet = false } = {}) {
|
|
|
124
151
|
}
|
|
125
152
|
|
|
126
153
|
async function cmdDoctor(cwd, flags) {
|
|
127
|
-
const {
|
|
128
|
-
|
|
154
|
+
const {
|
|
155
|
+
generateAstroidProject,
|
|
156
|
+
generateAstroidScaffoldFiles,
|
|
157
|
+
astroidUsesQueues,
|
|
158
|
+
astroidCrons,
|
|
159
|
+
checkWranglerPreviews,
|
|
160
|
+
generateAstroidReleaseWorkflow,
|
|
161
|
+
ASTROID_RELEASE_WORKFLOW_PATH,
|
|
162
|
+
} = await import(GENERATORS_URL);
|
|
129
163
|
const { config, path: configPath } = await loadConfig(cwd, flags.config);
|
|
130
164
|
|
|
131
165
|
const problems = []; // { level: "error" | "warn", msg }
|
|
@@ -264,6 +298,31 @@ async function cmdDoctor(cwd, flags) {
|
|
|
264
298
|
}
|
|
265
299
|
}
|
|
266
300
|
|
|
301
|
+
// 1c. The release workflow at the repository root: a tag on `main` moves
|
|
302
|
+
// `deploy/production`, which Workers Builds deploys. Stale is an error for
|
|
303
|
+
// the same reason the trio's is: it decides which commits reach production.
|
|
304
|
+
const root = gitRoot(cwd);
|
|
305
|
+
if (!root) {
|
|
306
|
+
warn(`not in a git checkout, so ${ASTROID_RELEASE_WORKFLOW_PATH} can't be checked.`);
|
|
307
|
+
} else {
|
|
308
|
+
const abs = join(root, ASTROID_RELEASE_WORKFLOW_PATH);
|
|
309
|
+
if (!existsSync(abs))
|
|
310
|
+
err(`${ASTROID_RELEASE_WORKFLOW_PATH} is missing — run \`astroid generate\`.`);
|
|
311
|
+
else if (readFileSync(abs, "utf8") !== generateAstroidReleaseWorkflow())
|
|
312
|
+
err(`${ASTROID_RELEASE_WORKFLOW_PATH} is stale — run \`astroid generate\`.`);
|
|
313
|
+
else ok(`${ASTROID_RELEASE_WORKFLOW_PATH} is up to date`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 2b. Staging: the `previews` block (louise-toolkit ADR 0017). A Preview
|
|
317
|
+
// inherits nothing, so a binding left out crashes it and one copied from
|
|
318
|
+
// production writes production data; see src/project/previews.ts.
|
|
319
|
+
if (existsSync(wranglerPath)) {
|
|
320
|
+
const previews = checkWranglerPreviews(readFileSync(wranglerPath, "utf8"));
|
|
321
|
+
for (const m of previews.ok) ok(m);
|
|
322
|
+
for (const m of previews.warnings) warn(m);
|
|
323
|
+
for (const m of previews.errors) err(m);
|
|
324
|
+
}
|
|
325
|
+
|
|
267
326
|
// 3. migrations directory: the D1 `migrations_dir` wrangler.jsonc declares,
|
|
268
327
|
// or `migrations/`, the generated default, when it names none. A site that
|
|
269
328
|
// keeps its migrations under another name (drizzle/) isn't missing them.
|
|
@@ -403,7 +462,10 @@ function provisionPlan(facts) {
|
|
|
403
462
|
}
|
|
404
463
|
for (const { binding, id } of facts.kv) {
|
|
405
464
|
if (isPlaceholder(id)) {
|
|
406
|
-
|
|
465
|
+
// The placeholder names the namespace (`<run: wrangler kv namespace
|
|
466
|
+
// create acme-rl>`); an older one without a name falls back to the binding.
|
|
467
|
+
const title = id.match(/kv namespace create ([^\s>]+)/)?.[1] ?? binding;
|
|
468
|
+
steps.push({ kind: "kv", name: title, binding, args: ["kv", "namespace", "create", title] });
|
|
407
469
|
}
|
|
408
470
|
}
|
|
409
471
|
// Queues carry no id, so there's no placeholder to test—creating one that
|
|
@@ -531,7 +593,7 @@ async function cmdDeploy(cwd, flags, rest) {
|
|
|
531
593
|
(rows) => rows.find((r) => typeof r.title === "string" && r.title.endsWith(s.name))?.id,
|
|
532
594
|
);
|
|
533
595
|
if (id) {
|
|
534
|
-
wrangler = patchKvId(wrangler, s.
|
|
596
|
+
wrangler = patchKvId(wrangler, s.binding, id);
|
|
535
597
|
writeFileSync(wranglerPath, wrangler);
|
|
536
598
|
out(` ↳ ${s.name} id = ${id}`);
|
|
537
599
|
} else {
|
|
@@ -597,10 +659,294 @@ Usage:
|
|
|
597
659
|
astroid dev [...astro args] regenerate, then run \`astro dev\`
|
|
598
660
|
astroid build [...astro args] regenerate, then run \`astro build\`
|
|
599
661
|
astroid deploy [--dry-run] [--yes] [--local] provision bindings + migrate + secrets + deploy
|
|
662
|
+
astroid ship production | preview migrate D1, then deploy or preview (Workers Builds runs this)
|
|
663
|
+
astroid provision [--dry-run] [--yes] create the resources wrangler.jsonc names by placeholder, staging included
|
|
600
664
|
|
|
601
665
|
New project: pnpm create astroid@latest
|
|
602
666
|
`;
|
|
603
667
|
|
|
668
|
+
// `astroid provision` creates what `wrangler.jsonc` still names by placeholder,
|
|
669
|
+
// top level and `previews` alike, and writes each new ID back in place of its
|
|
670
|
+
// placeholder. It never deploys, so it's safe to run before a site's first
|
|
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.
|
|
679
|
+
async function cmdProvision(cwd, rest) {
|
|
680
|
+
const dryRun = rest.includes("--dry-run");
|
|
681
|
+
const assumeYes = rest.includes("--yes") || rest.includes("-y");
|
|
682
|
+
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
683
|
+
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
|
|
684
|
+
|
|
685
|
+
const {
|
|
686
|
+
provisionPlan,
|
|
687
|
+
applyProvisionedId,
|
|
688
|
+
stagingSecretSteps,
|
|
689
|
+
secretNamesFromList,
|
|
690
|
+
TURNSTILE_TEST_SECRET,
|
|
691
|
+
} = await import(GENERATORS_URL);
|
|
692
|
+
let text = readFileSync(wranglerPath, "utf8");
|
|
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");
|
|
707
|
+
|
|
708
|
+
out("astroid provision — plan:\n");
|
|
709
|
+
if (plan.steps.length === 0) out(" (nothing to create: every binding has an ID)");
|
|
710
|
+
for (const s of plan.steps) {
|
|
711
|
+
const note = s.kind === "r2" ? " (an existing bucket is fine)" : "";
|
|
712
|
+
out(` wrangler ${s.args.join(" ")}${note}`);
|
|
713
|
+
}
|
|
714
|
+
const envAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
715
|
+
if (!plan.hasAccount && !envAccount) {
|
|
716
|
+
out(
|
|
717
|
+
"\n ! No account_id in wrangler.jsonc and no CLOUDFLARE_ACCOUNT_ID, so wrangler picks one.",
|
|
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
|
+
}
|
|
738
|
+
}
|
|
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;
|
|
748
|
+
out("\nSecrets Store secrets it binds; create any that don't exist yet:");
|
|
749
|
+
for (const s of byHand) {
|
|
750
|
+
out(
|
|
751
|
+
` [${s.environment}] wrangler secrets-store secret create ${s.storeId} ` +
|
|
752
|
+
`--name ${s.secretName} --scopes workers --remote`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
if (dryRun) {
|
|
758
|
+
printSecrets();
|
|
759
|
+
out("\n(dry run — nothing created)");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if ((plan.steps.length > 0 || toCreate.length > 0) && !assumeYes) {
|
|
763
|
+
if (!process.stdin.isTTY)
|
|
764
|
+
fail("Refusing to create resources non-interactively. Re-run with --yes.");
|
|
765
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
766
|
+
const answer = (await rl.question("\nCreate the above? [y/N] ")).trim().toLowerCase();
|
|
767
|
+
rl.close();
|
|
768
|
+
if (answer !== "y" && answer !== "yes") {
|
|
769
|
+
out("Aborted.");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!wranglerBin) fail("Could not find `wrangler` in this project.");
|
|
775
|
+
for (const s of plan.steps) {
|
|
776
|
+
out(`\n▸ wrangler ${s.args.join(" ")}`);
|
|
777
|
+
// A create that fails because the resource exists is fine: the lookup
|
|
778
|
+
// below finds it, so a re-run after a partial one picks up where it was.
|
|
779
|
+
spawnSync(process.execPath, [wranglerBin, ...s.args], { cwd, stdio: "inherit" });
|
|
780
|
+
if (s.kind === "r2") continue;
|
|
781
|
+
const id =
|
|
782
|
+
s.kind === "d1"
|
|
783
|
+
? lookupId(
|
|
784
|
+
wranglerBin,
|
|
785
|
+
cwd,
|
|
786
|
+
["d1", "list", "--json"],
|
|
787
|
+
(rows) => rows.find((r) => r.name === s.name)?.uuid,
|
|
788
|
+
)
|
|
789
|
+
: lookupId(
|
|
790
|
+
wranglerBin,
|
|
791
|
+
cwd,
|
|
792
|
+
["kv", "namespace", "list"],
|
|
793
|
+
(rows) => rows.find((r) => r.title === s.name)?.id,
|
|
794
|
+
);
|
|
795
|
+
if (!id) fail(`Couldn't find the ID of ${s.name} after creating it. Fill it in by hand.`);
|
|
796
|
+
text = applyProvisionedId(text, s.placeholder, id);
|
|
797
|
+
writeFileSync(wranglerPath, text);
|
|
798
|
+
out(` ↳ ${s.name} = ${id}`);
|
|
799
|
+
}
|
|
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
|
+
}
|
|
843
|
+
out("\n✓ Provisioned. Commit wrangler.jsonc; the dashboard steps are in the site's RUNBOOK.");
|
|
844
|
+
}
|
|
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
|
+
|
|
875
|
+
// `astroid ship` is what Workers Builds runs, so the deploy logic lives in the
|
|
876
|
+
// repository instead of a dashboard field. An account move once rewrote a
|
|
877
|
+
// site's dashboard deploy command to a bare `wrangler deploy`, and migrations
|
|
878
|
+
// silently stopped. Migrations run first, so new code never meets an old schema.
|
|
879
|
+
//
|
|
880
|
+
// astroid ship production the deploy/production build: migrate D1, then deploy
|
|
881
|
+
// astroid ship preview every other branch: migrate the staging D1, then
|
|
882
|
+
// `wrangler preview`, named for the branch
|
|
883
|
+
async function cmdShip(cwd, target) {
|
|
884
|
+
if (target !== "production" && target !== "preview") {
|
|
885
|
+
fail("Usage: astroid ship production | preview");
|
|
886
|
+
}
|
|
887
|
+
const wranglerBin = resolveBin(cwd, "wrangler", "wrangler");
|
|
888
|
+
if (!wranglerBin) fail("Could not find `wrangler` in this project.");
|
|
889
|
+
const run = (args) => {
|
|
890
|
+
out(`\n▸ wrangler ${args.join(" ")}`);
|
|
891
|
+
const res = spawnSync(process.execPath, [wranglerBin, ...args], { cwd, stdio: "inherit" });
|
|
892
|
+
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
893
|
+
};
|
|
894
|
+
const wranglerPath = join(cwd, "wrangler.jsonc");
|
|
895
|
+
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run inside an Astroid project.");
|
|
896
|
+
|
|
897
|
+
if (target === "production") {
|
|
898
|
+
run(["d1", "migrations", "apply", "DB", "--remote"]);
|
|
899
|
+
run(["deploy"]);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// The staging database is declared only inside `previews`, and wrangler's
|
|
904
|
+
// migrations command reads top-level `d1_databases`. So write a throwaway
|
|
905
|
+
// config naming it, derived from wrangler.jsonc on every run, rather than a
|
|
906
|
+
// second committed file that could drift from the binding.
|
|
907
|
+
const { parseJsonc } = await import(GENERATORS_URL);
|
|
908
|
+
const config = parseJsonc(readFileSync(wranglerPath, "utf8"));
|
|
909
|
+
const prodDb = (config.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
910
|
+
const stagingDb = (config.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
911
|
+
if (stagingDb && prodDb) {
|
|
912
|
+
const migrationsDir = resolve(cwd, prodDb.migrations_dir ?? "migrations");
|
|
913
|
+
const tmp = join(cwd, ".wrangler", "astroid-preview-migrations.jsonc");
|
|
914
|
+
mkdirSync(dirname(tmp), { recursive: true });
|
|
915
|
+
writeFileSync(
|
|
916
|
+
tmp,
|
|
917
|
+
JSON.stringify(
|
|
918
|
+
{
|
|
919
|
+
d1_databases: [
|
|
920
|
+
{
|
|
921
|
+
binding: "PREVIEW_DB",
|
|
922
|
+
database_name: stagingDb.database_name,
|
|
923
|
+
database_id: stagingDb.database_id,
|
|
924
|
+
migrations_dir: migrationsDir,
|
|
925
|
+
},
|
|
926
|
+
],
|
|
927
|
+
},
|
|
928
|
+
null,
|
|
929
|
+
2,
|
|
930
|
+
),
|
|
931
|
+
);
|
|
932
|
+
run(["d1", "migrations", "apply", "PREVIEW_DB", "--remote", "--config", tmp]);
|
|
933
|
+
} else {
|
|
934
|
+
out("\n(no staging D1 in `previews`, so no staging migrations to apply)");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
|
|
938
|
+
// DNS label, so a branch like feature/12-login becomes feature-12-login.
|
|
939
|
+
const branch = process.env.WORKERS_CI_BRANCH;
|
|
940
|
+
const name = branch
|
|
941
|
+
? branch
|
|
942
|
+
.toLowerCase()
|
|
943
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
944
|
+
.replace(/^-+|-+$/g, "")
|
|
945
|
+
.slice(0, 63)
|
|
946
|
+
: undefined;
|
|
947
|
+
run(["preview", ...(name ? ["--name", name] : [])]);
|
|
948
|
+
}
|
|
949
|
+
|
|
604
950
|
async function main() {
|
|
605
951
|
const { command, flags, rest } = parseArgs(process.argv.slice(2));
|
|
606
952
|
const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
|
|
@@ -622,6 +968,12 @@ async function main() {
|
|
|
622
968
|
case "deploy":
|
|
623
969
|
await cmdDeploy(cwd, flags, rest);
|
|
624
970
|
break;
|
|
971
|
+
case "ship":
|
|
972
|
+
await cmdShip(cwd, rest[0]);
|
|
973
|
+
break;
|
|
974
|
+
case "provision":
|
|
975
|
+
await cmdProvision(cwd, rest);
|
|
976
|
+
break;
|
|
625
977
|
case "help":
|
|
626
978
|
case "--help":
|
|
627
979
|
case "-h":
|
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;
|
|
@@ -62,8 +62,12 @@ export function generateAstroidGalleryPage(config) {
|
|
|
62
62
|
" // No DB binding yet (pre-provision)—render the empty state.",
|
|
63
63
|
"}",
|
|
64
64
|
"",
|
|
65
|
+
"// This environment's media base: production's, or a staging Preview's own",
|
|
66
|
+
"// `/media`, where the Preview's uploads live.",
|
|
67
|
+
`const mediaBase = (env.MEDIA_URL || ${JSON.stringify(mediaBase)}).replace(/\\/+$/, "");`,
|
|
68
|
+
"",
|
|
65
69
|
"const items: GalleryItem[] = rows.map((row) => ({",
|
|
66
|
-
|
|
70
|
+
" src: `${mediaBase}/${row.key}`,",
|
|
67
71
|
' // An asset with no alt gets "" rather than its filename: an empty alt makes',
|
|
68
72
|
" // a screen reader skip a decorative tile, while a filename is read aloud",
|
|
69
73
|
" // character by character and tells the listener nothing.",
|
package/dist/project/actions.js
CHANGED
|
@@ -61,6 +61,7 @@ export function generateAstroidActions(config) {
|
|
|
61
61
|
// as a literal, and an unused import is a lint error in its own file.
|
|
62
62
|
...(columnsOverride ? [] : [" ASTROID_SETTINGS_COLUMNS,"]),
|
|
63
63
|
" ASTROID_SETTINGS_IMAGE_KEYS,",
|
|
64
|
+
" astroidMediaBase,",
|
|
64
65
|
" astroidPagesCollection,",
|
|
65
66
|
'} from "astroidjs";',
|
|
66
67
|
'import astroidConfig from "../../astroid.config.js";',
|
|
@@ -117,7 +118,11 @@ export function generateAstroidActions(config) {
|
|
|
117
118
|
" // than copied—a second literal here is a list that drifts from the",
|
|
118
119
|
" // one the routes check against, and nothing would fail when it did.",
|
|
119
120
|
...settingsExtra,
|
|
120
|
-
|
|
121
|
+
" // Read on each save, so a staging Preview accepts images from its own",
|
|
122
|
+
" // `/media` (see astroidMediaBase).",
|
|
123
|
+
" get mediaBase() {",
|
|
124
|
+
" return astroidMediaBase(astroidConfig);",
|
|
125
|
+
" },",
|
|
121
126
|
" }),",
|
|
122
127
|
" ),",
|
|
123
128
|
" },",
|
package/dist/project/generate.js
CHANGED
|
@@ -209,10 +209,11 @@ export function generateAstroidWrangler(config) {
|
|
|
209
209
|
p(" // KV: RL = the security rate limiter (it also holds the daily site-health");
|
|
210
210
|
p(" // summary under its own key—one small singleton blob, not worth a binding");
|
|
211
211
|
p(" // someone has to remember to provision); DRAFTS = the autosave write-buffer.");
|
|
212
|
-
p(" //
|
|
212
|
+
p(" // Named for the project, so two sites in one account don't collide.");
|
|
213
|
+
p(" // `astroid provision` creates each and fills in its id.");
|
|
213
214
|
p(' "kv_namespaces": [');
|
|
214
|
-
p(
|
|
215
|
-
p(
|
|
215
|
+
p(` { "binding": "RL", "id": "<run: wrangler kv namespace create ${key}-rl>" },`);
|
|
216
|
+
p(` { "binding": "DRAFTS", "id": "<run: wrangler kv namespace create ${key}-drafts>" },`);
|
|
216
217
|
p(" ],");
|
|
217
218
|
// Email Sending. NOT optional decoration: `src/env.d.ts` declares EMAIL as a
|
|
218
219
|
// required member, and Better Auth's magic-link path console-logs the link in
|
package/dist/project/index.d.ts
CHANGED
package/dist/project/index.js
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** One finding, in the order `doctor` prints them. */
|
|
2
|
+
export interface PreviewsFindings {
|
|
3
|
+
ok: string[];
|
|
4
|
+
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Parse JSONC: strip `//` and block comments outside strings, then trailing
|
|
9
|
+
* commas. `wrangler.jsonc` is written by hand and commented heavily, which is
|
|
10
|
+
* why `doctor` reads the rest of it by regex; this check needs the structure.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseJsonc(text: string): unknown;
|
|
13
|
+
/**
|
|
14
|
+
* Check the `previews` block of a `wrangler.jsonc` against its production
|
|
15
|
+
* settings. Returns what passed and what didn't; an error means a Preview
|
|
16
|
+
* would crash or touch production.
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkWranglerPreviews(text: string): PreviewsFindings;
|