@pithy-sh/cli 0.1.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/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ZoneInfo } from "@pithy-sh/cloudflare/src/zones/zonesManager";
|
|
5
|
+
import { DOMAIN_ENVIRONMENTS, WorkerDomain, type WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Asking an adopter where a Worker will answer, against reality rather than as free text.
|
|
9
|
+
*
|
|
10
|
+
* ## Against the account, not against a text box
|
|
11
|
+
*
|
|
12
|
+
* A domain has two parts an adopter can get wrong independently: the hostname, and the zone it sits
|
|
13
|
+
* under. Free text accepts both mistakes silently, and neither surfaces until `pithy deploy` returns a
|
|
14
|
+
* Cloudflare error naming neither value. Offering the account's real zones turns the second half into a
|
|
15
|
+
* choice, and lets the first half be checked against it — so a typo fails here, with a list of what
|
|
16
|
+
* actually exists.
|
|
17
|
+
*
|
|
18
|
+
* **Free text stays** for the account that cannot be reached: no token, no `Zone:Read`, an offline
|
|
19
|
+
* laptop, or a domain not yet on Cloudflare. Requiring the network to answer a config question would
|
|
20
|
+
* make `pithy init` a command that fails without credentials, which it has never been.
|
|
21
|
+
*
|
|
22
|
+
* ## Skippable, and re-runnable
|
|
23
|
+
*
|
|
24
|
+
* A project without a domain yet is legitimate — most are, on the first day. Skipping writes no
|
|
25
|
+
* `domains` block at all, and the address resolver falls through to the route and then to `BASE_URL`
|
|
26
|
+
* exactly as it does for a project that predates the declaration. Adding one later is a config edit plus
|
|
27
|
+
* a deploy, never a rescaffold.
|
|
28
|
+
*
|
|
29
|
+
* This module is pure: it decides *what to ask* and *what an answer means*, and the command layer owns
|
|
30
|
+
* the `@clack/prompts` calls. That is what makes the interesting half testable without a TTY.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One environment's question, and the zones it can be answered from. */
|
|
34
|
+
export interface DomainQuestion {
|
|
35
|
+
/** The environment being asked about — `staging` or `prod`, never `dev`. */
|
|
36
|
+
env: string;
|
|
37
|
+
/** The prompt text, in the house convention: a short question, no trailing period. */
|
|
38
|
+
message: string;
|
|
39
|
+
/** A placeholder showing the shape of an answer for this environment. */
|
|
40
|
+
placeholder: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What an adopter answered for one environment. An empty hostname means "skip this one". */
|
|
44
|
+
export interface DomainAnswer {
|
|
45
|
+
/** The environment. */
|
|
46
|
+
env: string;
|
|
47
|
+
/** The hostname they gave, trimmed. Empty to skip. */
|
|
48
|
+
hostname: string;
|
|
49
|
+
/** The zone they chose, when a picker was offered and they picked one. */
|
|
50
|
+
zone?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Why a zone picker could not be offered, for the caller to say out loud before falling back to text. */
|
|
54
|
+
export type ZoneUnavailable = "no-credentials" | "no-access" | "no-zones";
|
|
55
|
+
|
|
56
|
+
/** The questions to ask, one per managed environment. `dev` is never asked about — it has no domain. */
|
|
57
|
+
export function domainQuestions(workerName: string): DomainQuestion[] {
|
|
58
|
+
return DOMAIN_ENVIRONMENTS.map((env) => ({
|
|
59
|
+
env,
|
|
60
|
+
message: `Domain for ${workerName} in ${env}`,
|
|
61
|
+
placeholder: env === "prod" ? "api.example.com" : `${env}.api.example.com`,
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The zone that owns a hostname, from the account's real list — longest match, or null.
|
|
67
|
+
*
|
|
68
|
+
* Longest match is the only correct rule when an account holds both `example.com` and `eu.example.com`:
|
|
69
|
+
* `api.eu.example.com` belongs to the latter, and taking the first would attach the Worker to the wrong
|
|
70
|
+
* zone. No public-suffix guess is used, because a zone can itself be a subdomain.
|
|
71
|
+
*/
|
|
72
|
+
export function zoneForHostname(hostname: string, zones: readonly ZoneInfo[]): ZoneInfo | null {
|
|
73
|
+
const candidates = zones.filter((zone) => hostname === zone.name || hostname.endsWith(`.${zone.name}`));
|
|
74
|
+
if (candidates.length === 0) return null;
|
|
75
|
+
return candidates.reduce((longest, zone) => (zone.name.length > longest.name.length ? zone : longest));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How a zone reads in a picker. A zone that is not yet active is shown and labeled, never hidden:
|
|
80
|
+
* hiding it makes the account look like it does not have the domain, which is the more confusing failure.
|
|
81
|
+
*/
|
|
82
|
+
export function describeZone(zone: ZoneInfo): string {
|
|
83
|
+
return zone.status === "active" ? zone.name : `${zone.name} (${zone.status})`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Why a hostname was refused, in brand voice — a problem line and what to do about it. */
|
|
87
|
+
export interface DomainRejection {
|
|
88
|
+
/** What is wrong. */
|
|
89
|
+
message: string;
|
|
90
|
+
/** What to do next. */
|
|
91
|
+
action: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Turn answers into a validated `domains` block, or the first reason one cannot be built.
|
|
96
|
+
*
|
|
97
|
+
* Validation goes through `WorkerDomain` rather than being re-implemented, so the prompt refuses exactly
|
|
98
|
+
* what the config would refuse — a scheme, a path, a port, or a hostname outside its own zone. A prompt
|
|
99
|
+
* that accepted something the config rejects would move the failure from a question to a stack trace.
|
|
100
|
+
*
|
|
101
|
+
* An answer with no hostname is a skip, and skipping every environment yields `undefined` rather than an
|
|
102
|
+
* empty object: a project with no domains should have no `domains` key at all.
|
|
103
|
+
*/
|
|
104
|
+
export function buildDomains(
|
|
105
|
+
answers: readonly DomainAnswer[],
|
|
106
|
+
): { domains: WorkerDomains | undefined } | { rejected: DomainRejection } {
|
|
107
|
+
const domains: Record<string, { pattern: string; zone: string }> = {};
|
|
108
|
+
|
|
109
|
+
for (const answer of answers) {
|
|
110
|
+
const hostname = answer.hostname.trim();
|
|
111
|
+
if (!hostname) continue;
|
|
112
|
+
|
|
113
|
+
// Absent a picker, the zone is inferred as the registrable pair — a reasonable default for the
|
|
114
|
+
// common `api.example.com` case, and one the adopter can correct in the config. Where a picker ran,
|
|
115
|
+
// the account's own answer wins over any inference.
|
|
116
|
+
const zone = answer.zone?.trim() || inferZone(hostname);
|
|
117
|
+
const parsed = WorkerDomain.safeParse({ pattern: hostname, zone });
|
|
118
|
+
if (!parsed.success) {
|
|
119
|
+
return {
|
|
120
|
+
rejected: {
|
|
121
|
+
message: `\`${hostname}\` is not a domain this Worker can answer on in ${answer.env}.`,
|
|
122
|
+
action:
|
|
123
|
+
parsed.error.issues[0]?.message ??
|
|
124
|
+
"Give a bare hostname like `api.example.com` — no scheme, no path, no port.",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
domains[answer.env] = parsed.data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { domains: Object.keys(domains).length === 0 ? undefined : (domains as WorkerDomains) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The registrable pair of a hostname — `api.example.com` → `example.com`.
|
|
136
|
+
*
|
|
137
|
+
* A deliberate guess, used **only** when the account could not be reached, and it is wrong for a
|
|
138
|
+
* multi-part public suffix (`example.co.uk`) and for an account whose zone is itself a subdomain. That is
|
|
139
|
+
* acceptable precisely because it is the offline path: the value lands in `pithy.config.ts` where the
|
|
140
|
+
* adopter can see and correct it, rather than being buried in a generated wrangler file. With the account
|
|
141
|
+
* reachable, `zoneForHostname` answers from the real list and this is never called.
|
|
142
|
+
*/
|
|
143
|
+
function inferZone(hostname: string): string {
|
|
144
|
+
const labels = hostname.split(".");
|
|
145
|
+
return labels.length <= 2 ? hostname : labels.slice(-2).join(".");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One `<env>: { pattern, zone },` line, at the given indent. Both renderers below emit the same entries;
|
|
150
|
+
* only where they sit differs.
|
|
151
|
+
*/
|
|
152
|
+
function domainEntries(domains: WorkerDomains, indent: string): string[] {
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
for (const env of DOMAIN_ENVIRONMENTS) {
|
|
155
|
+
const domain = domains[env as keyof WorkerDomains];
|
|
156
|
+
if (!domain) continue;
|
|
157
|
+
lines.push(
|
|
158
|
+
`${indent}${env}: { pattern: ${JSON.stringify(domain.pattern)}, zone: ${JSON.stringify(domain.zone)} },`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The hoisted `const DOMAINS = { … };` a scaffolded `pithy.config.ts` carries, filled in.
|
|
166
|
+
*
|
|
167
|
+
* Hoisted rather than nested because the Worker's public origin is derived from it on the very next line
|
|
168
|
+
* — `originFor(compositionEnvironment(), DOMAINS)` — and a value inside the config object literal cannot
|
|
169
|
+
* be read by that same literal. That derivation is the whole point: it is what lets every capability take
|
|
170
|
+
* `PUBLIC_ORIGIN` instead of asking the adopter for a URL (#256).
|
|
171
|
+
*
|
|
172
|
+
* The comment above the const belongs to the scaffold and is not restated here — this replaces the
|
|
173
|
+
* declaration alone, so an adopter who has rewritten that comment keeps it.
|
|
174
|
+
*/
|
|
175
|
+
export function renderDomainsConst(domains: WorkerDomains): string {
|
|
176
|
+
return ["const DOMAINS = {", ...domainEntries(domains, " "), "};"].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `domains` block as it is written into a `pithy.config.ts` scaffolded **before** the hoisted const —
|
|
181
|
+
* a key inside the config object. Kept for those projects; a config carrying the const takes
|
|
182
|
+
* {@link renderDomainsConst} instead.
|
|
183
|
+
*/
|
|
184
|
+
export function renderDomainsBlock(domains: WorkerDomains): string {
|
|
185
|
+
const lines = [" // Where this Worker answers, per environment."];
|
|
186
|
+
lines.push(" // `routes` and `vars.BASE_URL` in wrangler.jsonc are generated from this — declare it once here.");
|
|
187
|
+
lines.push(" domains: {");
|
|
188
|
+
lines.push(...domainEntries(domains, " "));
|
|
189
|
+
lines.push(" },");
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { DOMAIN_ENVIRONMENTS, type WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
7
|
+
import { isSourceEnvironment } from "../provision/featureConfig";
|
|
8
|
+
import { loadProject, loadProjectEnvironments, loadWorkerConfig, loadWorkerDomains } from "./config";
|
|
9
|
+
import {
|
|
10
|
+
type AddressStanza,
|
|
11
|
+
resolveWorkerAddress,
|
|
12
|
+
type WorkerAddress,
|
|
13
|
+
type WorkerAddressSource,
|
|
14
|
+
} from "./workerAddress";
|
|
15
|
+
import { discoverWorkers } from "./workers";
|
|
16
|
+
import { readOptionalWranglerConfig } from "./wrangler";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* **Every origin a deployed Worker answers on is one its configuration names, and every origin its
|
|
20
|
+
* configuration names has something configured to serve it.**
|
|
21
|
+
*
|
|
22
|
+
* That is the whole invariant — both directions of it — and #253 and #264 are what happen without each
|
|
23
|
+
* half. Anything derived from an environment's public origin — an auth `baseURL`, an OAuth callback, a
|
|
24
|
+
* magic-link URL, a CSRF allowed-origin — has to answer "what is this environment's origin?". When
|
|
25
|
+
* nothing in the config
|
|
26
|
+
* answers, every caller invents one, and the inventions are wrong in different directions: production's
|
|
27
|
+
* origin emails real users magic links into production from a staging deploy (the shape the first
|
|
28
|
+
* adopter shipped), `http://localhost:…` serves links that go nowhere, and a throw at module scope turns
|
|
29
|
+
* an auth-config typo into a total outage. There is no good answer at the point of use, because by then
|
|
30
|
+
* the information is already missing.
|
|
31
|
+
*
|
|
32
|
+
* So the question is asked here, once, from files — and it is asked in **both** directions, because a
|
|
33
|
+
* config and the origins it serves fall out of step three ways.
|
|
34
|
+
*
|
|
35
|
+
* ## One: the config names no origin at all
|
|
36
|
+
*
|
|
37
|
+
* `resolveWorkerAddress` is the one resolver, and it already reads the three places an address can be
|
|
38
|
+
* stated: the `domains` declaration, a hand-written `routes` pattern, and a hand-set `vars.BASE_URL`. If
|
|
39
|
+
* all three are empty for an environment, nothing downstream can compute an origin and this refuses.
|
|
40
|
+
*
|
|
41
|
+
* Note what that does *not* refuse. A project that predates the declaration and wrote its own route is
|
|
42
|
+
* fine. A project on a custom declared environment — `live`, which `WorkerDomains` has no key for — is
|
|
43
|
+
* fine the moment it sets `vars.BASE_URL`, which is the same thing the declaration generates. The
|
|
44
|
+
* refusal is aimed at the third state the issue names, where the origin is neither declared nor
|
|
45
|
+
* derivable and each caller guesses.
|
|
46
|
+
*
|
|
47
|
+
* ## Two: the config names an origin and nothing serves it
|
|
48
|
+
*
|
|
49
|
+
* **Every origin the config names has to have something in that config configured to serve it**, and until
|
|
50
|
+
* #264 nothing asked. `resolveWorkerAddress` takes the `domains` declaration as authoritative — rightly,
|
|
51
|
+
* it is what the `routes` entry is generated *from* — so "board answers on `https://app.example.com`" was
|
|
52
|
+
* asserted from the declaration alone, and whether any route pointed there was never a question. Only
|
|
53
|
+
* `applyDomains` ever writes that route, only an interactive `pithy init` or `pithy worker` ever calls it,
|
|
54
|
+
* and `askDomains` returns nothing at all when a session is not interactive. So an adopter who declared
|
|
55
|
+
* `domains` by hand — the documented way to add an environment — had a declaration with no route behind it,
|
|
56
|
+
* and every check here was green.
|
|
57
|
+
*
|
|
58
|
+
* Then this file's own remedy for fault three below finished the job: it told them to set
|
|
59
|
+
* `"workers_dev": false`, which closed the *only* origin that was actually serving. Doctor, `pithy deploy`
|
|
60
|
+
* and the post-deploy probe all stayed green over a Worker reachable at no address.
|
|
61
|
+
*
|
|
62
|
+
* The rule is asked as one question of the resolved address — *what in this config serves this host?* — and
|
|
63
|
+
* not as a list of key combinations, because a list is what missed this one. A route pattern that covers
|
|
64
|
+
* the hostname serves it; for a `workers.dev` hostname, `workers_dev` not being off serves it. Nothing
|
|
65
|
+
* else can.
|
|
66
|
+
*
|
|
67
|
+
* ## Three: `workers.dev` serves an origin nothing named
|
|
68
|
+
*
|
|
69
|
+
* A Worker deployed with a custom domain **still answers on `<name>.<subdomain>.workers.dev`**, because
|
|
70
|
+
* wrangler's `workers_dev` defaults to `true` and declaring `routes` does not change that. `preview_urls`
|
|
71
|
+
* then defaults to whatever `workers_dev` is, so every deployed version is reachable there too. That
|
|
72
|
+
* second origin is undeclared, and everything derived from the declaration is wrong on it: `BASE_URL`
|
|
73
|
+
* names the custom domain, so OAuth callbacks and magic links point away from the host in use; the CSRF
|
|
74
|
+
* same-origin gate allows the custom domain, so mutating cookie routes fail there — reachable, and broken
|
|
75
|
+
* in the half that decides who you are; and anything bound to the hostname rather than the script (WAF
|
|
76
|
+
* rules, Access policies, per-hostname rate limits) does not apply at all.
|
|
77
|
+
*
|
|
78
|
+
* **The fault is the absence of a decision, not the decision.** An explicit `"workers_dev": true` is an
|
|
79
|
+
* adopter saying out loud that the subdomain is an origin they want — a team wanting the `workers.dev`
|
|
80
|
+
* URL for staging before DNS is cut over is a real case — and a named origin satisfies the invariant. It
|
|
81
|
+
* is the *unset* key that means nobody chose, and that is what this reports.
|
|
82
|
+
*
|
|
83
|
+
* ## Where it is answered
|
|
84
|
+
*
|
|
85
|
+
* `pithy deploy --env <name>` refuses, because it already knows the environment and the config and it is
|
|
86
|
+
* the last moment before the mistake becomes real — the same shape as its refusal of a binding with no id
|
|
87
|
+
* (#240). `pithy doctor` reports the same drift so it is findable before a deploy is attempted. One
|
|
88
|
+
* reader, so the two can never disagree about what "declared" means.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/** Which of the three ways a config and the origins it serves fall out of step. Three remedies. */
|
|
92
|
+
export type OriginFault =
|
|
93
|
+
/** Nothing in the config states an address for this environment, so no caller can compute one. */
|
|
94
|
+
| "no-origin"
|
|
95
|
+
/** An origin is named and **nothing in the config serves it**, so the Worker answers at no address. */
|
|
96
|
+
| "unserved-origin"
|
|
97
|
+
/** An origin is named and served, and `workers.dev` serves a second one that nothing decided about. */
|
|
98
|
+
| "workers-dev-open";
|
|
99
|
+
|
|
100
|
+
/** One Worker-and-environment whose named and served origins do not line up. */
|
|
101
|
+
export interface OriginDrift {
|
|
102
|
+
/** The Worker's `apps/<name>` directory. */
|
|
103
|
+
worker: string;
|
|
104
|
+
/** The environment. */
|
|
105
|
+
env: string;
|
|
106
|
+
/** Which fault this is. */
|
|
107
|
+
fault: OriginFault;
|
|
108
|
+
/** The origin the config does name, or `null` when it names none. Evidence, never a secret. */
|
|
109
|
+
origin: string | null;
|
|
110
|
+
/**
|
|
111
|
+
* Where the unserved origin was named, on `unserved-origin` alone — and the whole remedy turns on it.
|
|
112
|
+
*
|
|
113
|
+
* A `declaration` is *generated from*, so `pithy worker sync` writes the route that was never written.
|
|
114
|
+
* A hand-set `vars.BASE_URL` is generated from nothing, so there is no command to offer and the route
|
|
115
|
+
* is the adopter's to write. Offering `worker sync` there would send them to a command that reads a
|
|
116
|
+
* `domains` block they do not have and reports, correctly and uselessly, that it wrote nothing.
|
|
117
|
+
*/
|
|
118
|
+
source?: WorkerAddressSource;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What `doctor` learned. Listed positively, so an inconclusive read never gates CI. */
|
|
122
|
+
export type OriginsState =
|
|
123
|
+
/** Every declared environment names every origin it serves, and serves every origin it names. */
|
|
124
|
+
| "ok"
|
|
125
|
+
/** The root config would not load, so there is no declared set to walk. */
|
|
126
|
+
| "could-not-check"
|
|
127
|
+
/** An environment's named and served origins disagree. Established from local files alone. */
|
|
128
|
+
| "drifted";
|
|
129
|
+
|
|
130
|
+
/** What `doctor` reports about this project's origins. */
|
|
131
|
+
export interface OriginsCheck {
|
|
132
|
+
state: OriginsState;
|
|
133
|
+
drift: OriginDrift[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `wrangler.jsonc` keys this reads: the address inputs, plus the `workers_dev` decision. */
|
|
137
|
+
interface OriginStanza extends AddressStanza {
|
|
138
|
+
workers_dev?: unknown;
|
|
139
|
+
}
|
|
140
|
+
interface OriginWrangler extends OriginStanza {
|
|
141
|
+
env?: Record<string, OriginStanza | undefined>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Is this hostname on `workers.dev`?
|
|
146
|
+
*
|
|
147
|
+
* Asked because an adopter whose *named* origin is the subdomain — the `workers.dev`-only project, which
|
|
148
|
+
* states it in `vars.BASE_URL` — is naming exactly the origin `workers_dev` serves. Turning it off would
|
|
149
|
+
* be the contradiction; leaving it on is the configuration working.
|
|
150
|
+
*/
|
|
151
|
+
function isWorkersDevHost(hostname: string): boolean {
|
|
152
|
+
return hostname === "workers.dev" || hostname.endsWith(".workers.dev");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `workers_dev` decision in force for one environment: the stanza's own, else the top level's, else
|
|
157
|
+
* `undefined` for "nobody decided".
|
|
158
|
+
*
|
|
159
|
+
* `undefined` is not `false`. Wrangler documents the default as `true` and documents nothing about
|
|
160
|
+
* `routes` changing it, so an absent key is the subdomain being served — which is the whole finding. It
|
|
161
|
+
* is kept distinct from an explicit `true` because the two mean opposite things here: one is a decision,
|
|
162
|
+
* the other is its absence.
|
|
163
|
+
*/
|
|
164
|
+
function workersDevDecision(config: OriginWrangler, stanza: OriginStanza | undefined): boolean | undefined {
|
|
165
|
+
const value = stanza?.workers_dev ?? config.workers_dev;
|
|
166
|
+
return typeof value === "boolean" ? value : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every route pattern an environment's stanza states, in either form wrangler accepts. */
|
|
170
|
+
function routePatterns(stanza: OriginStanza | undefined): string[] {
|
|
171
|
+
const entries = [...(Array.isArray(stanza?.routes) ? stanza.routes : []), stanza?.route];
|
|
172
|
+
return entries
|
|
173
|
+
.map((entry) => (typeof entry === "string" ? entry : typeof entry?.pattern === "string" ? entry.pattern : ""))
|
|
174
|
+
.filter((pattern) => pattern.length > 0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Does one route pattern cover this hostname?
|
|
179
|
+
*
|
|
180
|
+
* A pattern is a matcher, not a literal: it may carry a path (`example.com/api/*`, dropped — a host is
|
|
181
|
+
* either matched or it is not) and `*` stands for any run of characters, so `*.example.com/*` covers
|
|
182
|
+
* `app.example.com`. Matching on equality alone would report a fault against an adopter whose wildcard
|
|
183
|
+
* route has been serving the host for a year, which is the fastest way to get a check ignored.
|
|
184
|
+
*/
|
|
185
|
+
function routeCovers(pattern: string, hostname: string): boolean {
|
|
186
|
+
const host = pattern.split("/")[0] ?? "";
|
|
187
|
+
if (host.length === 0) return false;
|
|
188
|
+
if (host === hostname) return true;
|
|
189
|
+
if (!host.includes("*")) return false;
|
|
190
|
+
const expression = host
|
|
191
|
+
.split("*")
|
|
192
|
+
.map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
193
|
+
.join(".*");
|
|
194
|
+
return new RegExp(`^${expression}$`).test(hostname);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* **What in this config serves this address?** — the one question, asked of the one resolved origin.
|
|
199
|
+
*
|
|
200
|
+
* Two answers exist and there is no third. A route pattern that covers the hostname serves it. A
|
|
201
|
+
* `workers.dev` hostname is served by the subdomain itself, unless `workers_dev` has been turned off —
|
|
202
|
+
* which is the same fault from the other end, and needs no separate rule: an adopter whose named origin
|
|
203
|
+
* *is* the subdomain, with `"workers_dev": false` beside it, has closed the only thing serving them.
|
|
204
|
+
*/
|
|
205
|
+
function servesOrigin(
|
|
206
|
+
address: WorkerAddress,
|
|
207
|
+
stanza: OriginStanza | undefined,
|
|
208
|
+
workersDev: boolean | undefined,
|
|
209
|
+
): boolean {
|
|
210
|
+
if (routePatterns(stanza).some((pattern) => routeCovers(pattern, address.hostname))) return true;
|
|
211
|
+
return isWorkersDevHost(address.hostname) && workersDev !== false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One Worker's declared `domains`, **and whether its config could be asked at all**.
|
|
216
|
+
*
|
|
217
|
+
* The two must not arrive as one. A config that will not import — dependencies not installed, most
|
|
218
|
+
* often — returns no declaration, and a reader that took that as "declares none" would refuse a deploy
|
|
219
|
+
* for an environment whose domain is sitting in the file it could not open, telling the adopter to
|
|
220
|
+
* declare what they already declared. That was not hypothetical: it is what this did on a freshly
|
|
221
|
+
* scaffolded project before `bun install`, and the refusal named the wrong fix.
|
|
222
|
+
*
|
|
223
|
+
* So `loaded` is carried, and a negative finding is only ever made against a config that was read. The
|
|
224
|
+
* `workers.dev` finding is unaffected either way, because its evidence is the `wrangler.jsonc` alone.
|
|
225
|
+
*/
|
|
226
|
+
async function workerDomains(workerDir: string): Promise<{ domains: WorkerDomains | undefined; loaded: boolean }> {
|
|
227
|
+
try {
|
|
228
|
+
return { domains: loadWorkerDomains(await loadWorkerConfig(workerDir)), loaded: true };
|
|
229
|
+
} catch {
|
|
230
|
+
// A config that will not import has its own, better error waiting one step later — the same rule
|
|
231
|
+
// `unprovisionedBindings` states. This reader gates a deploy; it does not diagnose a broken config.
|
|
232
|
+
return { domains: undefined, loaded: false };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every Worker-and-environment in `environments` whose named and served origins disagree, in worker then
|
|
238
|
+
* environment order.
|
|
239
|
+
*
|
|
240
|
+
* Files only, offline, and no account call — the same standard the other deploy gate is held to. A
|
|
241
|
+
* process with no `wrangler.jsonc` (a Vite frontend in the dev set) answers on nothing and is skipped.
|
|
242
|
+
*/
|
|
243
|
+
export async function originDrift(projectDir: string, environments: readonly string[]): Promise<OriginDrift[]> {
|
|
244
|
+
const drift: OriginDrift[] = [];
|
|
245
|
+
for (const target of await discoverWorkers(projectDir)) {
|
|
246
|
+
if (target.hasWrangler === false) continue;
|
|
247
|
+
const config = (await readOptionalWranglerConfig(target.dir).catch(() => null)) as OriginWrangler | null;
|
|
248
|
+
if (!config) continue;
|
|
249
|
+
const { domains, loaded } = await workerDomains(target.dir);
|
|
250
|
+
const worker = basename(target.dir);
|
|
251
|
+
for (const env of environments) {
|
|
252
|
+
const stanza = config.env?.[env];
|
|
253
|
+
const address = resolveWorkerAddress({ environment: env, domains, stanza });
|
|
254
|
+
if (!address) {
|
|
255
|
+
// Only against a config that was actually read. "This declares no domain" is a negative claim,
|
|
256
|
+
// and a `pithy.config.ts` nobody could import is exactly what might have declared one.
|
|
257
|
+
if (loaded) drift.push({ worker, env, fault: "no-origin", origin: null });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const workersDev = workersDevDecision(config, stanza);
|
|
261
|
+
// The origin is named. Is anything serving it? One fault per environment, and this is the one that
|
|
262
|
+
// comes first: a Worker answering at no address makes the question of a *second* origin moot, and
|
|
263
|
+
// the one remedy — `pithy worker sync` — writes the route and the `workers_dev` decision together,
|
|
264
|
+
// so naming both faults would be two sentences about one edit.
|
|
265
|
+
if (!servesOrigin(address, stanza, workersDev)) {
|
|
266
|
+
drift.push({ worker, env, fault: "unserved-origin", origin: address.url, source: address.source });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// A named origin that is itself the subdomain leaves nothing unnamed, whatever `workers_dev` says.
|
|
270
|
+
if (isWorkersDevHost(address.hostname)) continue;
|
|
271
|
+
if (workersDev === undefined) {
|
|
272
|
+
drift.push({ worker, env, fault: "workers-dev-open", origin: address.url });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return drift;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One drift, as the sentence that fits it. Three faults, three remedies, and no two are each other's. */
|
|
280
|
+
export function describeOriginDrift(drift: OriginDrift): string {
|
|
281
|
+
if (drift.fault === "no-origin") {
|
|
282
|
+
return `${drift.worker} has no origin for ${drift.env}. ${originRemedy(drift.env)}`;
|
|
283
|
+
}
|
|
284
|
+
if (drift.fault === "unserved-origin") {
|
|
285
|
+
return `${drift.worker} answers on nothing in ${drift.env}: ${drift.origin} is its origin and no route in env.${drift.env} serves it. ${routeRemedy(drift)}`;
|
|
286
|
+
}
|
|
287
|
+
return `${drift.worker} answers on ${drift.origin} in ${drift.env}, and also on workers.dev, which nothing declared. Set "workers_dev": false in env.${drift.env} of its wrangler.jsonc — or "workers_dev": true to say you meant both.`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The hostname a route pattern would have to carry, from the origin this reported. */
|
|
291
|
+
function hostnameOf(origin: string | null): string {
|
|
292
|
+
try {
|
|
293
|
+
return new URL(origin ?? "").hostname;
|
|
294
|
+
} catch {
|
|
295
|
+
return origin ?? "";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* How to make something serve an origin already named. Three answers, because the remedy is whatever the
|
|
301
|
+
* origin was named *by*.
|
|
302
|
+
*
|
|
303
|
+
* A `domains` declaration is the one that has a command: `pithy worker sync` writes the route from it, and
|
|
304
|
+
* that command exists because before #264 nothing non-interactive did. A hand-set `vars.BASE_URL` was
|
|
305
|
+
* generated from nothing, so the route is written by hand. And an origin on `workers.dev` needs no route
|
|
306
|
+
* at all — the subdomain serves it, and turning `workers_dev` back on is the whole fix.
|
|
307
|
+
*/
|
|
308
|
+
function routeRemedy(drift: OriginDrift): string {
|
|
309
|
+
const hostname = hostnameOf(drift.origin);
|
|
310
|
+
if (isWorkersDevHost(hostname)) {
|
|
311
|
+
return `That host is the workers.dev subdomain and "workers_dev": false closed it. Set it back to true in env.${drift.env} of its wrangler.jsonc, or name an origin you do serve.`;
|
|
312
|
+
}
|
|
313
|
+
if (drift.source === "declaration") {
|
|
314
|
+
return `Run pithy worker sync to write the route from the domains declaration.`;
|
|
315
|
+
}
|
|
316
|
+
return `Add a routes entry for ${hostname} to env.${drift.env} in its wrangler.jsonc.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* How to give an environment an origin. Two answers, because `WorkerDomains` has keys for `staging` and
|
|
321
|
+
* `prod` only — a project on a custom declared environment cannot state one there, and telling it to
|
|
322
|
+
* would send it to a declaration that would not validate.
|
|
323
|
+
*/
|
|
324
|
+
function originRemedy(env: string): string {
|
|
325
|
+
// Widened deliberately: `DOMAIN_ENVIRONMENTS` is the literal union `WorkerDomains` has keys for, and
|
|
326
|
+
// the question here is about an arbitrary declared environment — `live` is a legal one and is exactly
|
|
327
|
+
// the case that needs the other answer.
|
|
328
|
+
if ((DOMAIN_ENVIRONMENTS as readonly string[]).includes(env)) {
|
|
329
|
+
return `Declare domains.${env} in the Worker's pithy.config.ts, or set vars.BASE_URL in env.${env} of its wrangler.jsonc.`;
|
|
330
|
+
}
|
|
331
|
+
return `Set vars.BASE_URL in env.${env} of its wrangler.jsonc — domains covers ${DOMAIN_ENVIRONMENTS.join(" and ")} only.`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Refuse to deploy into an environment whose named and served origins do not line up.
|
|
336
|
+
*
|
|
337
|
+
* **A feature environment is exempt, and deliberately.** It is ephemeral, created by pithy from a branch
|
|
338
|
+
* name, it has no declared domain by design, and `workers.dev` is exactly how it is reached — so holding
|
|
339
|
+
* it to a declaration it can never have would refuse every feature deploy. `isSourceEnvironment` is the
|
|
340
|
+
* same predicate the config-path resolver uses, so the two cannot disagree about which environments are
|
|
341
|
+
* a project's own.
|
|
342
|
+
*/
|
|
343
|
+
export async function assertOriginsDeclared(projectDir: string, env: string): Promise<void> {
|
|
344
|
+
if (!isSourceEnvironment(env)) return;
|
|
345
|
+
const drift = await originDrift(projectDir, [env]);
|
|
346
|
+
if (drift.length === 0) return;
|
|
347
|
+
const first = drift[0] as OriginDrift;
|
|
348
|
+
throw new ValidationError({
|
|
349
|
+
message:
|
|
350
|
+
first.fault === "unserved-origin"
|
|
351
|
+
? `${env} names an origin nothing serves: ${drift.map((entry) => entry.worker).join(", ")}.`
|
|
352
|
+
: `${env} serves an origin nothing declares: ${drift.map((entry) => entry.worker).join(", ")}.`,
|
|
353
|
+
action: drift.map(describeOriginDrift).join(" "),
|
|
354
|
+
detail: originDetail(first.fault),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The throw-site context behind each fault — why this one is worth a refusal. */
|
|
359
|
+
function originDetail(fault: OriginFault): string {
|
|
360
|
+
switch (fault) {
|
|
361
|
+
case "no-origin":
|
|
362
|
+
return "Every auth baseURL, OAuth callback, magic-link URL and CSRF allowed-origin is derived from this, and with nothing declared each of them invents its own.";
|
|
363
|
+
case "unserved-origin":
|
|
364
|
+
// The failure this refusal exists for: it deploys clean, verifies clean, and answers nowhere.
|
|
365
|
+
return "A declared domain and the route that serves it are two halves of one fact written in two files, and only applyDomains writes the second — so a domains block added by hand routes nowhere, and every check downstream reads the declaration rather than the route.";
|
|
366
|
+
case "workers-dev-open":
|
|
367
|
+
return "wrangler's workers_dev defaults to true and routes do not change it, so a Worker with a custom domain answers on its workers.dev subdomain too — and preview_urls follows workers_dev.";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The same question asked of every environment the project declares, for `pithy doctor`.
|
|
373
|
+
*
|
|
374
|
+
* `could-not-check` when the root config will not load: with no declaration there is no set to walk, and
|
|
375
|
+
* the `Project:` block already owns saying that a config would not read.
|
|
376
|
+
*/
|
|
377
|
+
export async function checkOrigins(projectDir: string): Promise<OriginsCheck> {
|
|
378
|
+
let environments: readonly string[];
|
|
379
|
+
try {
|
|
380
|
+
environments = loadProjectEnvironments(await loadProject(projectDir));
|
|
381
|
+
} catch {
|
|
382
|
+
return { state: "could-not-check", drift: [] };
|
|
383
|
+
}
|
|
384
|
+
const drift = await originDrift(projectDir, environments);
|
|
385
|
+
return { state: drift.length > 0 ? "drifted" : "ok", drift };
|
|
386
|
+
}
|