@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. 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
+ }