@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,166 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resolveDevSecretsTargets } from "../devSecrets/targets";
|
|
5
|
+
import { projectEnvironments } from "../project/config";
|
|
6
|
+
import { readOptionalWranglerConfig } from "../project/wrangler";
|
|
7
|
+
import { boundSecretNames } from "../provision/secretBindings";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* **Does every deployed environment bind the `cf-secrets-store` secrets its Workers read?** (#238)
|
|
11
|
+
*
|
|
12
|
+
* The safety net for a project that predates the stanza existing at all — which is every project
|
|
13
|
+
* scaffolded before it, including the adopter who found this by reading their own `wrangler.jsonc` and
|
|
14
|
+
* asking where the binding was. `pithy add` deliberately cannot write a `secret` binding (the entry needs
|
|
15
|
+
* a `store_id` and a `secret_name` that do not exist until an account has been reached) and
|
|
16
|
+
* `pithy secrets provision` is the step that comes back and writes it. Nothing said so. A Worker deployed
|
|
17
|
+
* without `SECRETS_ENCRYPTION_KEYS` boots and answers its first request with
|
|
18
|
+
* `Missing required bindings: secret:SECRETS_ENCRYPTION_KEYS`, and until this line the only thing that
|
|
19
|
+
* reported it was that response.
|
|
20
|
+
*
|
|
21
|
+
* **Files only, and it never asks the store.** Whether an *entry* exists is a question for the account,
|
|
22
|
+
* and provisioning is what asks it — a declared secret whose entry has not been written is reported
|
|
23
|
+
* rather than bound, because wrangler refuses a config naming an absent entry and binding one would turn
|
|
24
|
+
* a single missing value into a failed deploy of the whole Worker. So this reports the stanza against the
|
|
25
|
+
* registry and names the command that reconciles both.
|
|
26
|
+
*
|
|
27
|
+
* **`dev` never appears, and not by being filtered.** The environments walked are the ones the project
|
|
28
|
+
* declares, and `dev` is not among them. Local dev materialises every `cf-secrets-store` secret into the
|
|
29
|
+
* generated `.dev.vars` (#179), so a stanza there would name store entries a local run never reads.
|
|
30
|
+
*
|
|
31
|
+
* **Which secrets need a binding is not decided here.** {@link boundSecretNames} is the one predicate,
|
|
32
|
+
* shared with the writer in `provision/secretBindings.ts` — so a check that reported a binding the writer
|
|
33
|
+
* would never write, or missed one it would, is not a state these two can reach.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** One `cf-secrets-store` secret a deployed environment declares and does not bind. */
|
|
37
|
+
export interface MissingSecretBinding {
|
|
38
|
+
/** The Worker's name, as `pithy worker list` shows it. */
|
|
39
|
+
worker: string;
|
|
40
|
+
/** The declared environment whose stanza lacks it. */
|
|
41
|
+
env: string;
|
|
42
|
+
/** The binding name — the registry key, which is also the name every read site uses. */
|
|
43
|
+
binding: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What this check established. Listed positively, so an inconclusive read says so. */
|
|
47
|
+
export type SecretBindingsState =
|
|
48
|
+
/** Every declared environment binds every `cf-secrets-store` secret its Worker reads. */
|
|
49
|
+
| "ok"
|
|
50
|
+
/** A `wrangler.jsonc` would not parse, or the declared set would not load. */
|
|
51
|
+
| "could-not-check"
|
|
52
|
+
/** A deployed environment declares a secret it does not bind. */
|
|
53
|
+
| "unbound";
|
|
54
|
+
|
|
55
|
+
/** What `doctor` learned about this project's Secrets Store bindings. */
|
|
56
|
+
export interface SecretBindingsCheck {
|
|
57
|
+
state: SecretBindingsState;
|
|
58
|
+
missing: MissingSecretBinding[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `wrangler.jsonc` slice this reads: each environment stanza's `secrets_store_secrets` array. */
|
|
62
|
+
interface RawWrangler {
|
|
63
|
+
env?: Record<string, { secrets_store_secrets?: { binding?: string }[] } | undefined>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What {@link checkSecretBindings} needs. Both seams default to the real project's. */
|
|
67
|
+
export interface CheckSecretBindingsOptions {
|
|
68
|
+
/** The project root. */
|
|
69
|
+
projectDir: string;
|
|
70
|
+
/** The Workers whose registries declare the secrets. Defaults to every one composing `secrets`. */
|
|
71
|
+
targets?: { name: string; dir: string; registry: Record<string, unknown> }[];
|
|
72
|
+
/**
|
|
73
|
+
* The Workers whose `pithy.config.ts` would not import. Read only when {@link targets} is supplied —
|
|
74
|
+
* both halves of one resolution, so a seam cannot state one and let the other default to a lie.
|
|
75
|
+
*/
|
|
76
|
+
unresolvable?: readonly unknown[];
|
|
77
|
+
/** The environments to check. Defaults to the set the root `pithy.config.ts` declares. */
|
|
78
|
+
environments?: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compare each Worker's declared secrets against each declared environment's stanza. Never throws — a
|
|
83
|
+
* diagnostic has to work in the broken environment it exists to diagnose.
|
|
84
|
+
*
|
|
85
|
+
* `null` means no Worker composes `secrets`, so there is no registry and no question — the same
|
|
86
|
+
* discipline `checkDevSecrets` holds.
|
|
87
|
+
*
|
|
88
|
+
* **The unresolvable half is carried, not dropped (#199).** "This stanza binds no `X`" is a negative
|
|
89
|
+
* claim about a registry, and a Worker whose `pithy.config.ts` would not import is exactly the one that
|
|
90
|
+
* might have declared `X` — so one unreadable config makes the whole check `could-not-check` rather than
|
|
91
|
+
* a confident `ok`. `pithy doctor`'s `Dev secrets:` block is what names the Worker and the reason; this
|
|
92
|
+
* one only has to stop claiming something it could not establish.
|
|
93
|
+
*/
|
|
94
|
+
export async function checkSecretBindings(options: CheckSecretBindingsOptions): Promise<SecretBindingsCheck | null> {
|
|
95
|
+
const resolved =
|
|
96
|
+
options.targets === undefined
|
|
97
|
+
? await resolveDevSecretsTargets(options.projectDir).catch(() => ({ targets: [], unresolvable: [] }))
|
|
98
|
+
: { targets: options.targets, unresolvable: options.unresolvable ?? [] };
|
|
99
|
+
const targets = resolved.targets;
|
|
100
|
+
if (targets.length === 0 && resolved.unresolvable.length === 0) return null;
|
|
101
|
+
if (resolved.unresolvable.length > 0) return { state: "could-not-check", missing: [] };
|
|
102
|
+
|
|
103
|
+
let environments: readonly string[];
|
|
104
|
+
if (options.environments) {
|
|
105
|
+
environments = options.environments;
|
|
106
|
+
} else {
|
|
107
|
+
try {
|
|
108
|
+
environments = await projectEnvironments(options.projectDir);
|
|
109
|
+
} catch {
|
|
110
|
+
return { state: "could-not-check", missing: [] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const missing: MissingSecretBinding[] = [];
|
|
115
|
+
let unreadable = false;
|
|
116
|
+
for (const target of targets) {
|
|
117
|
+
const config = (await readOptionalWranglerConfig(target.dir).catch(() => undefined)) as
|
|
118
|
+
| RawWrangler
|
|
119
|
+
| null
|
|
120
|
+
| undefined;
|
|
121
|
+
if (config === undefined) {
|
|
122
|
+
// A `wrangler.jsonc` that will not parse states nothing about what it binds, and "this stanza is
|
|
123
|
+
// missing a binding" is a negative claim. The `Project health` block already says the file is
|
|
124
|
+
// broken, and saying it again in other words is how a report starts contradicting itself.
|
|
125
|
+
unreadable = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// No file at all is not a Worker with a broken config — a process in the dev set with no
|
|
129
|
+
// `wrangler.jsonc` declares no environments and binds nothing.
|
|
130
|
+
if (config === null) continue;
|
|
131
|
+
const declared = boundSecretNames(target.registry as Parameters<typeof boundSecretNames>[0]).sort();
|
|
132
|
+
for (const env of environments) {
|
|
133
|
+
const bound = new Set(
|
|
134
|
+
(config.env?.[env]?.secrets_store_secrets ?? []).map((entry) => entry.binding).filter(Boolean),
|
|
135
|
+
);
|
|
136
|
+
for (const binding of declared) {
|
|
137
|
+
if (!bound.has(binding)) missing.push({ worker: target.name, env, binding });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (missing.length > 0) return { state: "unbound", missing };
|
|
143
|
+
return { state: unreadable ? "could-not-check" : "ok", missing: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The lines the report prints, or none at all when there is nothing to say.
|
|
148
|
+
*
|
|
149
|
+
* One line per Worker-and-environment rather than per binding: the remedy is the same command for all of
|
|
150
|
+
* them, and a project composing four `cf-secrets-store` secrets would otherwise print eight sentences
|
|
151
|
+
* that differ only in a name. An adopter counts lines.
|
|
152
|
+
*/
|
|
153
|
+
export function describeSecretBindings(check: SecretBindingsCheck): string[] {
|
|
154
|
+
const grouped = new Map<string, MissingSecretBinding[]>();
|
|
155
|
+
for (const entry of check.missing) {
|
|
156
|
+
// `\0` written as the escape, never raw: a raw NUL makes the whole file binary to git, so it
|
|
157
|
+
// has no line diff and nothing in review can see what changed around it.
|
|
158
|
+
const key = `${entry.worker}\0${entry.env}`;
|
|
159
|
+
grouped.set(key, [...(grouped.get(key) ?? []), entry]);
|
|
160
|
+
}
|
|
161
|
+
return [...grouped.values()].map((entries) => {
|
|
162
|
+
const first = entries[0] as MissingSecretBinding;
|
|
163
|
+
const names = entries.map((entry) => entry.binding).join(", ");
|
|
164
|
+
return `${first.worker} env.${first.env} binds no ${names}. Run pithy secrets provision — it creates the store entries and writes the stanza.`;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type {
|
|
6
|
+
SettingsAccountReader,
|
|
7
|
+
SettingsEnvironment,
|
|
8
|
+
SettingsFinding,
|
|
9
|
+
} from "@pithy-sh/core/src/capability/settings";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether each composed capability's **settings work**, as opposed to being merely present (#411).
|
|
13
|
+
*
|
|
14
|
+
* Every other check in this directory asks a presence question — is the option key written, is the binding
|
|
15
|
+
* declared, is the ledger level. All of them can pass while `fromAddress` names a domain nobody onboarded,
|
|
16
|
+
* the signing key was never created, `BASE_URL` is staging's URL in production's config, and no mail
|
|
17
|
+
* arrives. Only the capability knows what its own values have to be, so the capability declares the check
|
|
18
|
+
* (`@pithy-sh/core/src/capability/settings.ts`) and this runs it.
|
|
19
|
+
*
|
|
20
|
+
* ## What this module is, and is not
|
|
21
|
+
*
|
|
22
|
+
* It is the **runner**: it walks the composed instances, hands each declared check its context, collects
|
|
23
|
+
* findings, and keeps a check that could not run apart from one that passed. Every source it needs is a
|
|
24
|
+
* seam — the environments, the account — so the whole of it is testable without a project or a network,
|
|
25
|
+
* which is the same reason `checkProjectName` takes its account probe as a parameter.
|
|
26
|
+
*
|
|
27
|
+
* It is not a check of its own. Nothing here knows what an email setting is, and nothing here reads a
|
|
28
|
+
* `pithy.manifest.json`: discovery keys on the capability instance, because a manifest is a file a
|
|
29
|
+
* capability package can be shipped without — `@pithy-sh/matchmaking` and `@pithy-sh/rating` both were,
|
|
30
|
+
* from the day they landed until #415 — and a manifest-keyed rule skips such a package in silence.
|
|
31
|
+
*
|
|
32
|
+
* ## The three rules it exists to keep
|
|
33
|
+
*
|
|
34
|
+
* - **`null` means the question does not arise.** No composed capability declares a check, so there is
|
|
35
|
+
* nothing to report — the same fact `checkDevSecrets` reports as `null` for a project with no registry.
|
|
36
|
+
* A capability declaring nothing is ordinary, and it is silent.
|
|
37
|
+
* - **A check that did not run is never rendered as a pass.** A local check that throws, and an account
|
|
38
|
+
* check the account never answered, land in {@link SettingsCheck.unchecked} — not in `findings`, and not
|
|
39
|
+
* in `checked`.
|
|
40
|
+
* - **Skipped is a third answer.** When the account cannot be reached — offline, no credentials, no answer
|
|
41
|
+
* — the account tier is `skipped` with the reason, no account check runs, and nothing gates the exit.
|
|
42
|
+
* A local finding gates. An account finding gates too, because by then the account *was* reached.
|
|
43
|
+
*
|
|
44
|
+
* Nothing here writes. `pithy upgrade`, `pithy <capability> provision`, and the command each finding names
|
|
45
|
+
* are what change anything.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** One Worker's name and the capabilities its own `pithy.config.ts` composes. */
|
|
49
|
+
export interface SettingsScope {
|
|
50
|
+
/** The Worker, as `apps/<name>` and the health block name it. */
|
|
51
|
+
name: string;
|
|
52
|
+
/** Its composed capability instances, app capability included. */
|
|
53
|
+
capabilities: readonly Capability[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Which half of a check something came from. The two cost different things and gate on different terms. */
|
|
57
|
+
export type SettingsTier = "local" | "account";
|
|
58
|
+
|
|
59
|
+
/** One finding, plus where it was found. */
|
|
60
|
+
export interface SettingsFindingEntry extends SettingsFinding {
|
|
61
|
+
/** The Worker whose composition produced it. */
|
|
62
|
+
worker: string;
|
|
63
|
+
/** The capability that reported it. */
|
|
64
|
+
capability: string;
|
|
65
|
+
/** Which tier reported it. */
|
|
66
|
+
tier: SettingsTier;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One capability-and-tier nobody could answer for. Never a pass, and never a fault. */
|
|
70
|
+
export interface SettingsUnchecked {
|
|
71
|
+
worker: string;
|
|
72
|
+
capability: string;
|
|
73
|
+
tier: SettingsTier;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The check's verdict, listed positively.
|
|
78
|
+
*
|
|
79
|
+
* - `ok` — every check that ran passed, and nothing was left unanswered.
|
|
80
|
+
* - `faults` — at least one setting does not work. This is what fails the exit.
|
|
81
|
+
* - `could-not-check` — nothing is wrong that anybody established, and something could not be asked.
|
|
82
|
+
*/
|
|
83
|
+
export type SettingsState = "ok" | "faults" | "could-not-check";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Why the account tier did not run.
|
|
87
|
+
*
|
|
88
|
+
* Four reasons, and they are not interchangeable in a report: `offline` is a decision somebody made,
|
|
89
|
+
* `no-credentials` is a project that has not been set up, `unreachable` is a network or an account that
|
|
90
|
+
* did not answer, and `not-declared` is nobody having asked the account anything. A single boolean would
|
|
91
|
+
* have said "not checked" to all four, which is the conflation `CloudflareAccessState` grew `not_checked`
|
|
92
|
+
* and `probe_failed` to avoid.
|
|
93
|
+
*/
|
|
94
|
+
export type SettingsAccountSkip = "offline" | "no-credentials" | "unreachable" | "not-declared";
|
|
95
|
+
|
|
96
|
+
/** Whether the account tier ran, and why not when it did not. */
|
|
97
|
+
export interface SettingsAccountTier {
|
|
98
|
+
state: "checked" | "skipped";
|
|
99
|
+
/** The reason, set exactly when `state` is `skipped`. */
|
|
100
|
+
reason: SettingsAccountSkip | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the whole check found, across every Worker. */
|
|
104
|
+
export interface SettingsCheck {
|
|
105
|
+
state: SettingsState;
|
|
106
|
+
/** Whether the account half ran at all. Reported whether or not it found anything. */
|
|
107
|
+
account: SettingsAccountTier;
|
|
108
|
+
/** Every capability whose check ran to completion, in Worker then composition order. */
|
|
109
|
+
checked: { worker: string; capability: string }[];
|
|
110
|
+
/** Every setting that does not work, in the order the checks reported them. */
|
|
111
|
+
findings: SettingsFindingEntry[];
|
|
112
|
+
/** Every capability-and-tier that could not be asked. */
|
|
113
|
+
unchecked: SettingsUnchecked[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The account, or the reason there is none to ask.
|
|
118
|
+
*
|
|
119
|
+
* Resolved once per run and shared by every capability: the credentials are account-scoped, so asking a
|
|
120
|
+
* second time could only produce a second answer to one question.
|
|
121
|
+
*/
|
|
122
|
+
export type SettingsAccountConnection =
|
|
123
|
+
| { state: "reachable"; reader: SettingsAccountReader }
|
|
124
|
+
| { state: "skipped"; reason: SettingsAccountSkip };
|
|
125
|
+
|
|
126
|
+
/** Everything the runner needs, all of it injectable. */
|
|
127
|
+
export interface CapabilitySettingsOptions {
|
|
128
|
+
/** The root config's `name` — the leading segment of every name this project provisions. */
|
|
129
|
+
project: string;
|
|
130
|
+
/** The Workers in scope. `--worker <name>` has already narrowed this; the runner never re-enumerates. */
|
|
131
|
+
workers: readonly SettingsScope[];
|
|
132
|
+
/**
|
|
133
|
+
* The environments a Worker declares, and the origin it answers on in each.
|
|
134
|
+
*
|
|
135
|
+
* Per Worker, because an origin is a Worker's own declaration — two Workers in one project answer on
|
|
136
|
+
* two hostnames, and a check told otherwise would report the wrong one as drift.
|
|
137
|
+
*/
|
|
138
|
+
environments: (worker: string) => Promise<readonly SettingsEnvironment[]>;
|
|
139
|
+
/**
|
|
140
|
+
* Reach the account. Called **at most once**, and only when some composed capability declares an account
|
|
141
|
+
* tier — a project whose capabilities ask the account nothing pays for no call, the same rule
|
|
142
|
+
* `probeAccountEvidence` follows.
|
|
143
|
+
*/
|
|
144
|
+
connect: () => Promise<SettingsAccountConnection>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every composed capability that declares a check, flattened with the Worker it came from. */
|
|
148
|
+
function declared(workers: readonly SettingsScope[]): { worker: string; capability: Capability }[] {
|
|
149
|
+
const found: { worker: string; capability: Capability }[] = [];
|
|
150
|
+
for (const worker of workers) {
|
|
151
|
+
for (const capability of worker.capabilities) {
|
|
152
|
+
if (capability.settings) found.push({ worker: worker.name, capability });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run every composed capability's settings check.
|
|
160
|
+
*
|
|
161
|
+
* Never throws — a diagnostic has to keep working in exactly the broken environment it exists to
|
|
162
|
+
* diagnose, and the checks here are adopter-supplied code reached through a live `import()`. Every throw
|
|
163
|
+
* becomes an `unchecked` entry naming the capability and the tier, which is the one actionable fact in it;
|
|
164
|
+
* nothing derived from what was thrown is kept, because a capability's own failure names config paths and
|
|
165
|
+
* sometimes values.
|
|
166
|
+
*/
|
|
167
|
+
export async function checkCapabilitySettings(options: CapabilitySettingsOptions): Promise<SettingsCheck | null> {
|
|
168
|
+
const entries = declared(options.workers);
|
|
169
|
+
// The question does not arise: no capability composed here has anything to say about its own settings.
|
|
170
|
+
if (entries.length === 0) return null;
|
|
171
|
+
|
|
172
|
+
const findings: SettingsFindingEntry[] = [];
|
|
173
|
+
const unchecked: SettingsUnchecked[] = [];
|
|
174
|
+
const checked: { worker: string; capability: string }[] = [];
|
|
175
|
+
|
|
176
|
+
// Resolved once, ahead of the loop, and only when something will ask it. A second call could answer
|
|
177
|
+
// differently, and a report whose two halves disagree about whether the account was reached is worse
|
|
178
|
+
// than one that never asked.
|
|
179
|
+
const wantsAccount = entries.some((entry) => entry.capability.settings?.account);
|
|
180
|
+
const connection: SettingsAccountConnection = wantsAccount
|
|
181
|
+
? await connectQuietly(options.connect)
|
|
182
|
+
: { state: "skipped", reason: "not-declared" };
|
|
183
|
+
|
|
184
|
+
for (const { worker, capability } of entries) {
|
|
185
|
+
const settings = capability.settings;
|
|
186
|
+
if (!settings) continue;
|
|
187
|
+
let context: { project: string; worker: string; environments: readonly SettingsEnvironment[] };
|
|
188
|
+
try {
|
|
189
|
+
context = { project: options.project, worker, environments: await options.environments(worker) };
|
|
190
|
+
} catch {
|
|
191
|
+
// Nothing was checked against nothing: without the declared set there is no environment to judge a
|
|
192
|
+
// per-environment value in, and a check run against an empty list would report a clean pass.
|
|
193
|
+
unchecked.push({ worker, capability: capability.name, tier: "local" });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let ranLocal = false;
|
|
198
|
+
try {
|
|
199
|
+
for (const finding of await settings.local(context)) {
|
|
200
|
+
findings.push({ ...finding, worker, capability: capability.name, tier: "local" });
|
|
201
|
+
}
|
|
202
|
+
ranLocal = true;
|
|
203
|
+
} catch {
|
|
204
|
+
unchecked.push({ worker, capability: capability.name, tier: "local" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (settings.account && connection.state === "reachable") {
|
|
208
|
+
try {
|
|
209
|
+
for (const finding of await settings.account({ ...context, account: connection.reader })) {
|
|
210
|
+
findings.push({ ...finding, worker, capability: capability.name, tier: "account" });
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
unchecked.push({ worker, capability: capability.name, tier: "account" });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (ranLocal) checked.push({ worker, capability: capability.name });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
// Listed positively, and a fault outranks an unanswered question: a project with one of each has
|
|
222
|
+
// something to fix, and the line that says so must not be softened into "could not check".
|
|
223
|
+
state: findings.length > 0 ? "faults" : unchecked.length > 0 ? "could-not-check" : "ok",
|
|
224
|
+
account:
|
|
225
|
+
connection.state === "reachable"
|
|
226
|
+
? { state: "checked", reason: null }
|
|
227
|
+
: { state: "skipped", reason: connection.reason },
|
|
228
|
+
checked,
|
|
229
|
+
findings,
|
|
230
|
+
unchecked,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reach the account, and treat a throw as unreachable rather than as a lost report.
|
|
236
|
+
*
|
|
237
|
+
* `try`/`catch` rather than `.catch()` for the reason `commands/doctor.ts` states about its own guards: a
|
|
238
|
+
* seam that throws before returning a promise is not a rejected promise, and `.catch()` never sees it.
|
|
239
|
+
*/
|
|
240
|
+
async function connectQuietly(connect: () => Promise<SettingsAccountConnection>): Promise<SettingsAccountConnection> {
|
|
241
|
+
try {
|
|
242
|
+
return await connect();
|
|
243
|
+
} catch {
|
|
244
|
+
return { state: "skipped", reason: "unreachable" };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* One finding as one sentence — the capability, the setting, the environment it is about, then the problem
|
|
250
|
+
* and the action.
|
|
251
|
+
*
|
|
252
|
+
* The `--json` `detail` and the source of the text block's two lines, so a consumer never has to
|
|
253
|
+
* reassemble the wording from the fields. The environment is in parentheses and absent when the finding is
|
|
254
|
+
* about every environment at once, which is the difference between one edit and three.
|
|
255
|
+
*/
|
|
256
|
+
export function describeSettingsFinding(entry: SettingsFindingEntry): string {
|
|
257
|
+
const where = entry.environment === null ? "" : ` (${entry.environment})`;
|
|
258
|
+
return `${entry.capability}: ${entry.setting}${where} — ${entry.problem} ${entry.action}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The account tier's own sentence — which of the four things happened, in the run's own words. */
|
|
262
|
+
export function describeSettingsAccount(tier: SettingsAccountTier): string {
|
|
263
|
+
if (tier.state === "checked") return "account checks ran";
|
|
264
|
+
if (tier.reason === "not-declared") return "no capability asks the account anything";
|
|
265
|
+
const why =
|
|
266
|
+
tier.reason === "offline"
|
|
267
|
+
? "offline"
|
|
268
|
+
: tier.reason === "no-credentials"
|
|
269
|
+
? "no Cloudflare credentials"
|
|
270
|
+
: "the account did not answer";
|
|
271
|
+
// Said out loud, every time: a tier nobody ran is not a tier that passed, and the one thing a reader
|
|
272
|
+
// must not take from a quiet report is that the account was checked.
|
|
273
|
+
return `account checks skipped (${why}) — nothing here was established about the account`;
|
|
274
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { SettingsAccountReader, SettingsEnvironment } from "@pithy-sh/core/src/capability/settings";
|
|
7
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import type { SecretProbe } from "@pithy-sh/secrets/src/cli/dispatch";
|
|
9
|
+
import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
|
|
10
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
11
|
+
import {
|
|
12
|
+
loadProject,
|
|
13
|
+
loadWorkerConfig,
|
|
14
|
+
loadWorkerDomains,
|
|
15
|
+
projectEnvironments,
|
|
16
|
+
requireProjectName,
|
|
17
|
+
} from "../project/config";
|
|
18
|
+
import { type AddressStanza, resolveWorkerAddress } from "../project/workerAddress";
|
|
19
|
+
import { readOptionalWranglerConfig } from "../project/wrangler";
|
|
20
|
+
import { checkCapabilitySettings, type SettingsAccountConnection, type SettingsCheck } from "./settings";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where `pithy doctor`'s settings check gets the two things it cannot invent: the environments a capability
|
|
24
|
+
* is judged against, and the account it may ask (#411).
|
|
25
|
+
*
|
|
26
|
+
* Split from `settings.ts` on purpose. That module is the runner — pure, seam-driven, and testable with no
|
|
27
|
+
* project and no network. This one is the wiring: it reads the adopter's config off disk and opens a
|
|
28
|
+
* Cloudflare client. Keeping them apart is what lets the three account outcomes (reached and clean, reached
|
|
29
|
+
* and failing, never reached) be stated as three tests rather than as three mocks of a network.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The environments this project declares, and the origin the given Worker answers on in each.
|
|
34
|
+
*
|
|
35
|
+
* Both halves come from the readers that already own them — `loadProjectEnvironments` for the declared set,
|
|
36
|
+
* `resolveWorkerAddress` for the address — so a capability's idea of "the origin for prod" is the same one
|
|
37
|
+
* `pithy deploy` refuses on and the `Origins:` block reports. `dev` never resolves to a public address, and
|
|
38
|
+
* an environment nothing serves carries `null` rather than being dropped: a check judging `BASE_URL` needs
|
|
39
|
+
* to know the environment exists even when nothing declares where it answers.
|
|
40
|
+
*
|
|
41
|
+
* A `wrangler.jsonc` or a `pithy.config.ts` that will not read costs the origins and nothing else. The
|
|
42
|
+
* declared set is the root config's, and without it there is no answer at all — which the runner turns
|
|
43
|
+
* into an unchecked capability rather than into a clean pass.
|
|
44
|
+
*/
|
|
45
|
+
export async function settingsEnvironments(projectDir: string, workerDir: string): Promise<SettingsEnvironment[]> {
|
|
46
|
+
const declared = await projectEnvironments(projectDir);
|
|
47
|
+
const config = (await readOptionalWranglerConfig(workerDir).catch(() => null)) as {
|
|
48
|
+
env?: Record<string, AddressStanza | undefined>;
|
|
49
|
+
} | null;
|
|
50
|
+
// A negative claim about a Worker's domains needs a config that was actually read: the `pithy.config.ts`
|
|
51
|
+
// nobody could import is exactly the one that might have declared one.
|
|
52
|
+
const domains = await loadWorkerConfig(workerDir)
|
|
53
|
+
.then((worker) => loadWorkerDomains(worker))
|
|
54
|
+
.catch(() => undefined);
|
|
55
|
+
return declared.map((name) => {
|
|
56
|
+
const address = resolveWorkerAddress({ environment: name, domains, stanza: config?.env?.[name] });
|
|
57
|
+
return { name, origin: address?.url ?? null };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What it takes to reach the account, all of it injectable so a unit test never calls out. */
|
|
62
|
+
export interface SettingsAccountOptions {
|
|
63
|
+
/** The account this project belongs to, from its own root `pithy.config.ts`. */
|
|
64
|
+
account: CloudflareAccountSelection | null;
|
|
65
|
+
/** The root config's `name` — the leading segment of the manager Workflow a secret question is asked of. */
|
|
66
|
+
project: string;
|
|
67
|
+
/** Whether this run refuses ambient credentials and every network call. */
|
|
68
|
+
offline: boolean;
|
|
69
|
+
/** Environment overlay seam, so a test resolves credentials without a `.dev.vars`. */
|
|
70
|
+
env?: NodeJS.ProcessEnv;
|
|
71
|
+
/** Home directory seam — the credentials file is resolved under it, exactly as every other doctor path is. */
|
|
72
|
+
homedir?: string;
|
|
73
|
+
/** Cloudflare client seam. */
|
|
74
|
+
connect?: (credentials: { accountId: string; apiToken: string }) => CloudflareClients;
|
|
75
|
+
/** Secret-probe seam — the manager Workflow that answers whether a `d1` secret has a value. */
|
|
76
|
+
probe?: (credentials: { accountId: string; apiToken: string }, project: string) => SecretProbe;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the account the account tier asks, or the reason there is none to ask.
|
|
81
|
+
*
|
|
82
|
+
* **Four skips, and they are not one boolean.** `offline` is a decision somebody made and is settled first,
|
|
83
|
+
* before any file is read. A pin the credentials contradict ends it as `unreachable` — the `Cloudflare:`
|
|
84
|
+
* block of the same report already names that fault, and one fact belongs in one line. Credentials that
|
|
85
|
+
* simply are not there are `no-credentials`, because a project that has not been provisioned yet is a
|
|
86
|
+
* legitimate state and calling it unreachable would blame a network nobody touched.
|
|
87
|
+
*
|
|
88
|
+
* The reader memoizes: every composed capability asks the same account, and a doctor run must cost one
|
|
89
|
+
* listing rather than one per capability.
|
|
90
|
+
*/
|
|
91
|
+
export async function settingsAccountConnection(options: SettingsAccountOptions): Promise<SettingsAccountConnection> {
|
|
92
|
+
if (options.offline) return { state: "skipped", reason: "offline" };
|
|
93
|
+
|
|
94
|
+
let vars: Record<string, string>;
|
|
95
|
+
try {
|
|
96
|
+
vars = cloudflareEnv({
|
|
97
|
+
account: options.account,
|
|
98
|
+
...(options.env ? { env: options.env } : {}),
|
|
99
|
+
...(options.homedir ? { homedir: options.homedir } : {}),
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return { state: "skipped", reason: "unreachable" };
|
|
103
|
+
}
|
|
104
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
105
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
106
|
+
if (!accountId || !apiToken) return { state: "skipped", reason: "no-credentials" };
|
|
107
|
+
|
|
108
|
+
const credentials = { accountId, apiToken };
|
|
109
|
+
const clients = (options.connect ?? ((creds) => new CloudflareClients(creds)))(credentials);
|
|
110
|
+
const probe = (
|
|
111
|
+
options.probe ?? ((creds, project) => buildSecretDispatcher(creds.accountId, creds.apiToken, project))
|
|
112
|
+
)(credentials, options.project);
|
|
113
|
+
|
|
114
|
+
let databases: Promise<readonly string[]> | undefined;
|
|
115
|
+
const zones = new Map<string, Promise<boolean>>();
|
|
116
|
+
|
|
117
|
+
const reader: SettingsAccountReader = {
|
|
118
|
+
d1Databases: () => {
|
|
119
|
+
databases ??= clients
|
|
120
|
+
.d1Provisioner()
|
|
121
|
+
.listDatabases()
|
|
122
|
+
.then((list) => list.map((entry) => entry.name));
|
|
123
|
+
return databases;
|
|
124
|
+
},
|
|
125
|
+
zone: (hostname) => {
|
|
126
|
+
const existing = zones.get(hostname);
|
|
127
|
+
if (existing) return existing;
|
|
128
|
+
const answer = clients
|
|
129
|
+
.zones()
|
|
130
|
+
.findZoneForHostname(hostname)
|
|
131
|
+
.then((zone) => zone !== null);
|
|
132
|
+
zones.set(hostname, answer);
|
|
133
|
+
return answer;
|
|
134
|
+
},
|
|
135
|
+
// `async` so a refusal is a rejected promise rather than a synchronous throw: the runner guards both,
|
|
136
|
+
// but a seam that throws before returning a promise is the shape `commands/doctor.ts` documents as the
|
|
137
|
+
// one `.catch()` never sees, and no caller of this should have to know which it is.
|
|
138
|
+
secret: async ({ name, environment }) => {
|
|
139
|
+
// **`dev` is refused rather than answered.** A `d1` secret's value is sealed under a master key that
|
|
140
|
+
// never leaves that environment's manager Worker, and `dev` has no manager — it is local Miniflare.
|
|
141
|
+
// Answering `false` would report a signing key as missing on every developer's machine, which is a
|
|
142
|
+
// fault nobody has and a report nobody would read twice.
|
|
143
|
+
if (environment === "dev") {
|
|
144
|
+
throw new InternalError({
|
|
145
|
+
message: "A dev secret cannot be asked of the account.",
|
|
146
|
+
action: "Ask about a deployed environment, or read the local value through pithy secrets edit.",
|
|
147
|
+
detail: `secret ${name} in dev has no manager Worker to answer`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return probe.probe({ env: environment as "staging" | "prod", name });
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return { state: "reachable", reader };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One Worker as `pithy doctor` already holds it: its name, its directory, and what it composes. */
|
|
157
|
+
export interface SettingsWorkerScope {
|
|
158
|
+
name: string;
|
|
159
|
+
dir: string;
|
|
160
|
+
capabilities: readonly Capability[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** What the default settings probe needs, all of it already resolved once by `buildDoctorReport`. */
|
|
164
|
+
export interface DoctorSettingsOptions {
|
|
165
|
+
projectDir: string;
|
|
166
|
+
/** The Workers in scope — already narrowed by `--worker`, so nothing here re-enumerates `apps/`. */
|
|
167
|
+
workers: readonly SettingsWorkerScope[];
|
|
168
|
+
/** The account this project belongs to, as the `Cloudflare:` block of the same report reads it. */
|
|
169
|
+
account: CloudflareAccountSelection | null;
|
|
170
|
+
/** Whether this run refuses ambient credentials and every network call. */
|
|
171
|
+
offline: boolean;
|
|
172
|
+
homedir?: string;
|
|
173
|
+
env?: NodeJS.ProcessEnv;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `pithy doctor`'s settings probe, wired to a real project.
|
|
178
|
+
*
|
|
179
|
+
* **The declaration test comes before the project name**, and that order is the point: a project whose
|
|
180
|
+
* capabilities declare no check has no settings question, and resolving a name it may not have would turn
|
|
181
|
+
* that silence into a `could-not-check`. `requireProjectName` and not `resolveProjectName`, because the
|
|
182
|
+
* name reaches an account resource name — `<project>-global-email-suppressions` — and a fallback-derived
|
|
183
|
+
* one would have the check reporting a database missing that is sitting there under the real name.
|
|
184
|
+
*/
|
|
185
|
+
export async function doctorSettingsCheck(options: DoctorSettingsOptions): Promise<SettingsCheck | null> {
|
|
186
|
+
if (!options.workers.some((worker) => worker.capabilities.some((capability) => capability.settings))) return null;
|
|
187
|
+
const project = requireProjectName(await loadProject(options.projectDir));
|
|
188
|
+
const dirs = new Map(options.workers.map((worker) => [worker.name, worker.dir]));
|
|
189
|
+
return checkCapabilitySettings({
|
|
190
|
+
project,
|
|
191
|
+
workers: options.workers.map((worker) => ({ name: worker.name, capabilities: worker.capabilities })),
|
|
192
|
+
environments: (worker) => settingsEnvironments(options.projectDir, dirs.get(worker) ?? options.projectDir),
|
|
193
|
+
connect: () =>
|
|
194
|
+
settingsAccountConnection({
|
|
195
|
+
account: options.account,
|
|
196
|
+
project,
|
|
197
|
+
offline: options.offline,
|
|
198
|
+
...(options.homedir ? { homedir: options.homedir } : {}),
|
|
199
|
+
...(options.env ? { env: options.env } : {}),
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
}
|