@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,155 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { basename } from "node:path";
5
+ import { loadProject, loadProjectEnvironments } from "../project/config";
6
+ import { discoverWorkers, type WorkerTarget } from "../project/workers";
7
+ import { readWranglerConfig } from "../project/wrangler";
8
+
9
+ /**
10
+ * **Does every Worker still declare the environments this project says it has?** (#241)
11
+ *
12
+ * The root `pithy.config.ts` declares the set; each Worker's `wrangler.jsonc` records which of them it has
13
+ * a stanza for. Before the declaration existed there was nothing to compare a stanza to — two Workers in
14
+ * one project could declare different environments and nothing reconciled them, and a project could
15
+ * acquire `env.live` that `pithy secrets provision` would never give a master key.
16
+ *
17
+ * Two different faults live here, and they are worth telling apart because the remedies are opposite:
18
+ *
19
+ * - **A disagreement.** A Worker declares an environment the project does not, or is missing one it does.
20
+ * Nothing has been provisioned under it; the fix is an edit to one file or the other.
21
+ * - **An orphan.** The same undeclared stanza, but carrying **resource ids** — so resources were created
22
+ * under a name this project no longer claims. That is a declaration changed *after* provisioning, and
23
+ * the answer is never "apply it": `<project>-<env>-<thing>` is recomputed, never stored, so renaming an
24
+ * environment does not rename a database. It orphans it, exactly as renaming `name` does. This reports
25
+ * it and stops.
26
+ *
27
+ * **Files only.** No account call, so it answers offline, in the project that is not working — and its
28
+ * verdict is established by this repo contradicting itself, which is the standard `doctorExitCode` holds
29
+ * a gating check to.
30
+ */
31
+
32
+ /** What this check established. Listed positively, so an inconclusive read never gates CI. */
33
+ export type EnvironmentsState =
34
+ /** Every Worker's stanzas are exactly the declared set. */
35
+ | "ok"
36
+ /** A `wrangler.jsonc` would not parse, the root config would not load, or the worker set would not enumerate. */
37
+ | "could-not-check"
38
+ /** A Worker's stanzas and the declaration contradict each other. Established from local files alone. */
39
+ | "drifted";
40
+
41
+ /** One Worker-and-environment disagreement. */
42
+ export interface EnvironmentDrift {
43
+ /** The Worker's `apps/<name>` directory. */
44
+ worker: string;
45
+ /** The environment the stanza names, or the declared one that has no stanza. */
46
+ env: string;
47
+ /** Which of the three faults this is. */
48
+ kind: "undeclared" | "orphaned" | "missing";
49
+ /** The resource ids found under an orphaned environment — the evidence, never a secret. */
50
+ resources: string[];
51
+ }
52
+
53
+ /** What `doctor` learned about this project's environments. */
54
+ export interface EnvironmentsCheck {
55
+ state: EnvironmentsState;
56
+ /** The declared set, in declaration order — so the report says what it compared against. */
57
+ declared: string[];
58
+ drift: EnvironmentDrift[];
59
+ }
60
+
61
+ /** The `wrangler.jsonc` keys this reads: the environment stanzas and the binding ids inside them. */
62
+ interface StanzaBindings {
63
+ d1_databases?: { binding?: string; database_id?: string }[];
64
+ kv_namespaces?: { binding?: string; id?: string }[];
65
+ r2_buckets?: { binding?: string; bucket_name?: string }[];
66
+ vectorize?: { binding?: string; index_name?: string }[];
67
+ env?: Record<string, StanzaBindings | undefined>;
68
+ }
69
+
70
+ /**
71
+ * The provisioned resources one stanza names — the evidence that separates "a stanza nobody deleted" from
72
+ * "resources nobody can find any more".
73
+ *
74
+ * An id, never a value: these are Cloudflare identifiers an adopter's `wrangler.jsonc` already commits, so
75
+ * naming them in a report costs nothing and is the only way the line is actionable. A binding with no id is
76
+ * deliberately not counted — that is the not-yet-provisioned shape #241 asks `doctor` to read as *not
77
+ * provisioned* rather than as broken.
78
+ */
79
+ function provisionedResources(stanza: StanzaBindings): string[] {
80
+ const found: string[] = [];
81
+ for (const entry of stanza.d1_databases ?? []) if (entry.database_id) found.push(`d1 ${entry.database_id}`);
82
+ for (const entry of stanza.kv_namespaces ?? []) if (entry.id) found.push(`kv ${entry.id}`);
83
+ for (const entry of stanza.r2_buckets ?? []) if (entry.bucket_name) found.push(`r2 ${entry.bucket_name}`);
84
+ for (const entry of stanza.vectorize ?? []) if (entry.index_name) found.push(`vectorize ${entry.index_name}`);
85
+ return found;
86
+ }
87
+
88
+ /** The declared environment set, or `null` when the root config would not read or would not validate. */
89
+ async function declaredEnvironments(projectDir: string): Promise<string[] | null> {
90
+ try {
91
+ return [...loadProjectEnvironments(await loadProject(projectDir))];
92
+ } catch {
93
+ // `checkProjectName` and the `Project:` block already own "the config would not load". A second block
94
+ // reporting it is how a report starts contradicting itself.
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** Compare every Worker's `env.<name>` stanzas against the project's declaration. */
100
+ export async function checkEnvironments(projectDir: string): Promise<EnvironmentsCheck> {
101
+ const declared = await declaredEnvironments(projectDir);
102
+ if (declared === null) return { state: "could-not-check", declared: [], drift: [] };
103
+
104
+ let workers: WorkerTarget[];
105
+ try {
106
+ workers = await discoverWorkers(projectDir);
107
+ } catch {
108
+ return { state: "could-not-check", declared, drift: [] };
109
+ }
110
+
111
+ const drift: EnvironmentDrift[] = [];
112
+ let unreadable = false;
113
+ for (const target of workers) {
114
+ // A process in the dev set with no `wrangler.jsonc` — a Vite frontend — declares no environments at
115
+ // all, which is not a disagreement with anything.
116
+ if (!target.hasWrangler) continue;
117
+ let config: StanzaBindings;
118
+ try {
119
+ config = (await readWranglerConfig(target.dir)) as StanzaBindings;
120
+ } catch {
121
+ unreadable = true;
122
+ continue;
123
+ }
124
+ const worker = basename(target.dir);
125
+ const stanzas = Object.entries(config.env ?? {});
126
+ for (const [env, stanza] of stanzas) {
127
+ if (declared.includes(env)) continue;
128
+ const resources = stanza ? provisionedResources(stanza) : [];
129
+ drift.push({ worker, env, kind: resources.length > 0 ? "orphaned" : "undeclared", resources });
130
+ }
131
+ for (const env of declared) {
132
+ if (stanzas.some(([name]) => name === env)) continue;
133
+ drift.push({ worker, env, kind: "missing", resources: [] });
134
+ }
135
+ }
136
+
137
+ if (drift.length > 0) return { state: "drifted", declared, drift };
138
+ return { state: unreadable ? "could-not-check" : "ok", declared, drift: [] };
139
+ }
140
+
141
+ /**
142
+ * One drift, as the sentence that fits it. Three faults, three remedies — and the orphan's remedy is
143
+ * deliberately not "change it back and carry on": the resources are still there under the old name, and
144
+ * only the adopter can say whether to adopt them or delete them.
145
+ */
146
+ export function describeEnvironmentDrift(drift: EnvironmentDrift, declared: string[]): string {
147
+ const set = declared.join(", ");
148
+ if (drift.kind === "missing") {
149
+ return `pithy.config.ts declares ${drift.env}, and this Worker has no env.${drift.env} stanza. Add one, or drop ${drift.env} from environments.`;
150
+ }
151
+ if (drift.kind === "undeclared") {
152
+ return `env.${drift.env} is a stanza pithy.config.ts does not declare (${set}). Add ${drift.env} to environments, or remove the stanza.`;
153
+ }
154
+ return `env.${drift.env} holds provisioned resources and pithy.config.ts no longer declares it (${set}). Renaming an environment does not rename what it provisioned — those resources are orphaned. Restore ${drift.env} to environments, or delete them in Cloudflare and remove the stanza.`;
155
+ }
@@ -0,0 +1,354 @@
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 AvailableManifests, availableManifests, type ManifestFault } from "../capabilities/manifests";
6
+ import type { MissingPrerequisite } from "../capabilities/prerequisites";
7
+ import {
8
+ type BindingDeclines,
9
+ type BuildReconcilePlanOptions,
10
+ buildReconcilePlan,
11
+ type EntitlementGap,
12
+ type ReadLedger,
13
+ type ReconcilePlan,
14
+ } from "../capabilities/reconcile";
15
+ import type { CloudflareAccountSelection } from "../cloudflare/config";
16
+ import type { ProjectLedger, readProjectLedger } from "../migrations/run";
17
+ import type { WorkerConfig } from "../project/config";
18
+
19
+ /**
20
+ * The read-only project-health engine behind `pithy doctor`'s `Project health` block — the *same*
21
+ * {@link buildReconcilePlan} `pithy upgrade` runs, rendered without writing (one engine, two commands).
22
+ * `upgrade` fixes the drift this reports; `doctor` only surfaces it and drives a non-zero exit so CI can
23
+ * gate on a project whose wiring has fallen out of sync with its installed capabilities.
24
+ *
25
+ * Health is **per Worker**, because the wiring is: each Worker under `apps/` has its own `pithy.config.ts`
26
+ * and `wrangler.jsonc`, so each drifts independently. One unhealthy Worker makes the project unhealthy.
27
+ */
28
+
29
+ /** The `config` check: capabilities whose `pithy.config.ts` registration is missing manifest options. */
30
+ export interface ConfigHealth {
31
+ ok: boolean;
32
+ /** Per drifting capability: the option keys not yet written into its registration. */
33
+ drift: { capability: string; keys: string[] }[];
34
+ }
35
+
36
+ /**
37
+ * The `bindings` check: required bindings absent from `wrangler.jsonc`, each with the envs that lack it —
38
+ * **and the Durable Object classes the Worker's entry does not export.**
39
+ *
40
+ * One check, because a Durable Object is one binding written in two files. The `durable_objects.bindings`
41
+ * entry names a `class_name` and wrangler resolves that name against the module `main` names; a Worker
42
+ * carrying the first and not the second is refused at deploy. Reporting only the half that lives in
43
+ * `wrangler.jsonc` is what let `doctor` call a project healthy that `wrangler deploy` would not take
44
+ * (#428).
45
+ */
46
+ export interface BindingHealth {
47
+ ok: boolean;
48
+ missing: { name: string; type: string; envs: string[] }[];
49
+ /** Durable Object classes bound in `wrangler.jsonc` that this Worker's entry does not export. */
50
+ missingExports: string[];
51
+ /**
52
+ * Optional bindings this Worker's `pithy.config.ts` declines, resolved against what it composes.
53
+ *
54
+ * Taken from the plan **by reference**, never re-projected: `groupMissingBindings` below constructs a
55
+ * fresh object per binding, and a field that had to survive that construction is a field the two
56
+ * commands would eventually disagree about — which is #440 itself, one level up.
57
+ *
58
+ * An honored decline does not fail `ok`. A decline that cannot be honored does, because it means
59
+ * the adopter believes a binding is being left out that is not.
60
+ */
61
+ declinedBindings: BindingDeclines;
62
+ }
63
+
64
+ /**
65
+ * The `migrations` check: the target environment's ledger against what this Worker declares — **both
66
+ * directions**.
67
+ *
68
+ * `pending` alone passed a database `pithy migrate` refused to touch. An extra applied migration is
69
+ * invisible to declared-minus-applied: nothing is missing, so nothing is pending, so the check was
70
+ * green while the migrator read the same ledger as a corrupted chain and applied nothing (#282). Each
71
+ * half fails the check on its own, because either one means the schema is not where the project says.
72
+ */
73
+ export interface MigrationHealth {
74
+ ok: boolean;
75
+ /**
76
+ * The environment's ledger, exactly as {@link readProjectLedger} answered it — the counts behind their
77
+ * discriminant rather than flattened onto this object (#371).
78
+ *
79
+ * Flattening was the fault. A database that could not be read contributed nothing to `pending`, so a
80
+ * project whose D1 was unreachable read as `0 pending` — the same two fields a healthy project has, and
81
+ * a green line about a schema nobody had compared.
82
+ */
83
+ ledger: ProjectLedger;
84
+ env: string;
85
+ }
86
+
87
+ /**
88
+ * The `entitlements` check: routes gated on an entitlement with no capability composed to resolve one.
89
+ * The seam fails closed, so this Worker would deny every gated route in production and look, to the
90
+ * runtime, exactly like a project full of unentitled users. Unlike the other three checks, `pithy upgrade`
91
+ * cannot fix it — which capability to compose is the adopter's decision, so this only ever reports.
92
+ */
93
+ export interface EntitlementHealth {
94
+ ok: boolean;
95
+ /**
96
+ * The scan's answer, exactly as the plan carried it (#371) — the file list behind its discriminant.
97
+ *
98
+ * A flat `gates: []` said "no gap" and "no scan" in the same two characters, and only one of those is
99
+ * good news.
100
+ */
101
+ gap: EntitlementGap;
102
+ }
103
+
104
+ /**
105
+ * The `prerequisites` check: a composed capability whose manifest declares a peer this Worker does not
106
+ * compose.
107
+ *
108
+ * **The only check here that is a boot failure rather than drift.** `createBackend` refuses to assemble
109
+ * on exactly this pair, so the Worker does not start at all — which is what `pithy add auth` used to
110
+ * leave behind, on a project this command called healthy (#273). It reports and does not fix, like the
111
+ * entitlement gap: `pithy upgrade` writes bindings and config keys, and composing a capability is a
112
+ * different kind of decision. The line names the command that makes it.
113
+ */
114
+ export interface PrerequisiteHealth {
115
+ ok: boolean;
116
+ /** Each composed capability paired with the peer it declares and this Worker lacks. */
117
+ missing: MissingPrerequisite[];
118
+ }
119
+
120
+ /** One Worker's five checks, when the plan behind them was built. `ok` is their AND. */
121
+ export interface WorkerChecks {
122
+ config: ConfigHealth;
123
+ bindings: BindingHealth;
124
+ migrations: MigrationHealth;
125
+ entitlements: EntitlementHealth;
126
+ prerequisites: PrerequisiteHealth;
127
+ }
128
+
129
+ /**
130
+ * One Worker's health — **or that this Worker could not be checked at all (#371)**.
131
+ *
132
+ * The plan behind a Worker's five checks reads that Worker's own `pithy.config.ts` and `wrangler.jsonc`
133
+ * and, through the ledger, its databases. Any of that can fail for reasons that belong to one Worker: a
134
+ * config that will not import, a stanza that will not parse. It used to throw out of the loop, and one
135
+ * Worker in that state erased every *other* Worker's config, bindings, migrations, entitlement and
136
+ * prerequisite lines — from the command whose whole job is to say which part of a project is broken.
137
+ *
138
+ * **The state rides on the value**, so an unchecked Worker cannot be rendered as a checked one. The five
139
+ * checks live behind `checked`, and `unavailable` carries nothing but the Worker's name: a Worker with no
140
+ * `ok`, no empty drift lists and no `0 pending` to mistake for a clean bill.
141
+ *
142
+ * This is the same treatment the *manifest* half of {@link buildProjectHealth} got under #184. It was
143
+ * applied to one loop in this file and not the other.
144
+ */
145
+ export type WorkerHealth =
146
+ | ({
147
+ /** The plan was built and every check ran. */
148
+ state: "checked";
149
+ /** The Worker's name, as `pithy worker list` shows it. */
150
+ worker: string;
151
+ /** The AND of the five checks below. */
152
+ ok: boolean;
153
+ } & WorkerChecks)
154
+ | {
155
+ /** The plan could not be built, so nothing is known about this Worker. */
156
+ state: "unavailable";
157
+ /** The Worker's name, as `pithy worker list` shows it. */
158
+ worker: string;
159
+ };
160
+
161
+ /**
162
+ * The `manifests` check: installed packages whose `pithy.manifest.json` is present and unusable.
163
+ *
164
+ * Project-wide rather than per Worker, because manifests resolve once from the project root. It fails the
165
+ * doctor exit for the same reason the others do: a capability nobody can read is a capability every check
166
+ * below silently leaves out, and `doctor` reporting a healthy project around that hole is what #184 was
167
+ * reported about. `pithy upgrade` cannot fix it — the manifest belongs to someone else's package.
168
+ */
169
+ export interface ManifestHealth {
170
+ ok: boolean;
171
+ faults: ManifestFault[];
172
+ }
173
+
174
+ /** The whole project's health: one entry per Worker, plus the project-wide manifest read. `ok` is the AND. */
175
+ export interface ProjectHealth {
176
+ ok: boolean;
177
+ workers: WorkerHealth[];
178
+ manifests: ManifestHealth;
179
+ }
180
+
181
+ /** The manifest-scan seam: defaults to {@link availableManifests}, the scan every capability command reads. */
182
+ export type ReadManifests = (projectDir: string) => Promise<AvailableManifests>;
183
+
184
+ /** The plan-builder seam: defaults to {@link buildReconcilePlan}, the engine `upgrade` shares. */
185
+ export type BuildPlan = (options: BuildReconcilePlanOptions) => Promise<ReconcilePlan>;
186
+
187
+ /** The shared engine, exported so a test can assert doctor and upgrade use one implementation. */
188
+ export const defaultBuildPlan: BuildPlan = buildReconcilePlan;
189
+
190
+ /** The minimum a health check needs to know about a Worker — what `resolveWorkers` already returns. */
191
+ export interface HealthWorker {
192
+ /** The Worker's name. */
193
+ name: string;
194
+ /** The Worker's directory (`apps/<name>/`) — the config and wrangler stanzas the plan reads. */
195
+ dir: string;
196
+ /** That Worker's composed capabilities, forwarded to the plan for its migration count. */
197
+ capabilities?: Capability[];
198
+ /**
199
+ * That Worker's own `pithy.config.ts`, as `resolveWorkers` already returns it. The plan reads
200
+ * `declinedBindings` off it — the whole object, because the reader also refuses a key that is nearly
201
+ * that one and can only see one if it is handed what the adopter wrote. Optional because the
202
+ * resolver is a test seam: a double with no config is a Worker that declines nothing.
203
+ */
204
+ config?: WorkerConfig;
205
+ }
206
+
207
+ /** Options for {@link buildProjectHealth}. */
208
+ export interface ProjectHealthOptions {
209
+ /** The project root — where the capability manifests resolve from. */
210
+ projectDir: string;
211
+ /** The environment the migration check is computed for. */
212
+ env: string;
213
+ /**
214
+ * The Cloudflare account this project belongs to, or `null` when it names none. `doctor` already
215
+ * resolves it for the `Cloudflare:` block; the pending-migration count inside each plan is the read
216
+ * that needs it, and it was reading whichever credentials file the machine defaulted to (#234).
217
+ */
218
+ account: CloudflareAccountSelection | null;
219
+ /** The Workers to check, in report order. Doctor resolves them once and passes them in. */
220
+ workers: HealthWorker[];
221
+ /** Test seam: read the migration ledger without a real Miniflare/D1 run. */
222
+ readLedger?: ReadLedger;
223
+ /** Test seam: substitute the plan builder. Defaults to the shared reconcile engine. */
224
+ buildPlan?: BuildPlan;
225
+ /** Test seam: substitute the manifest scan. Defaults to the real `node_modules/@pithy-sh` read. */
226
+ readManifests?: ReadManifests;
227
+ }
228
+
229
+ /** Group a plan's per-capability missing bindings into one entry per binding, listing the envs that lack it. */
230
+ function groupMissingBindings(plan: ReconcilePlan): BindingHealth["missing"] {
231
+ const byKey = new Map<string, { name: string; type: string; envs: string[] }>();
232
+ for (const cap of plan.perCapability) {
233
+ for (const binding of cap.missingBindings) {
234
+ const key = `${binding.name} ${binding.type}`;
235
+ const entry = byKey.get(key) ?? { name: binding.name, type: binding.type, envs: [] };
236
+ if (!entry.envs.includes(binding.env)) entry.envs.push(binding.env);
237
+ byKey.set(key, entry);
238
+ }
239
+ }
240
+ return [...byKey.values()];
241
+ }
242
+
243
+ /** Project one Worker's reconcile plan into its three health checks. */
244
+ function healthFromPlan(worker: string, plan: ReconcilePlan): WorkerHealth {
245
+ const drift = plan.perCapability
246
+ .filter((cap) => cap.missingConfigKeys.length > 0)
247
+ .map((cap) => ({ capability: cap.name, keys: cap.missingConfigKeys.map((key) => key.key) }));
248
+ const config: ConfigHealth = { ok: drift.length === 0, drift };
249
+
250
+ const missing = groupMissingBindings(plan);
251
+ // Deduplicated across capabilities: two capabilities binding one class is one missing export.
252
+ const missingExports = [...new Set(plan.perCapability.flatMap((cap) => cap.missingEntryExports))];
253
+ // A decline that cannot be honored is a defect in the declaration and fails the check. `unrecognized`
254
+ // does not: `pithy remove <capability>` produces it, and a red no command can clear is worse than the
255
+ // line that reports it.
256
+ const declinedBindings = plan.declinedBindings;
257
+ const badDeclines =
258
+ declinedBindings.state === "invalid" ||
259
+ declinedBindings.declines.some((decline) => decline.state === "required" || decline.state === "undeclinable");
260
+ const bindings: BindingHealth = {
261
+ ok: missing.length === 0 && missingExports.length === 0 && !badDeclines,
262
+ missing,
263
+ missingExports,
264
+ declinedBindings,
265
+ };
266
+
267
+ // `ok` only on a whole read with nothing on either side of it. A `partial` ledger is a database this
268
+ // check did not compare, and a check that did not run is not a check that passed — the same standard
269
+ // `pithy doctor` already applies to a manifest it could not parse (#184). It is also what today's
270
+ // behavior was: an unreadable ledger threw, and the exit was non-zero.
271
+ const ledger = plan.ledger;
272
+ const migrations: MigrationHealth = {
273
+ ok: ledger.state === "read" && ledger.pending === 0 && ledger.undeclared.length === 0,
274
+ ledger,
275
+ env: plan.env,
276
+ };
277
+
278
+ // `ok` only on a scan that ran and found nothing, on the same standard the migrations check applies: a
279
+ // check that did not run is not a check that passed.
280
+ const gap = plan.entitlements;
281
+ const entitlements: EntitlementHealth = { ok: gap.state === "read" && gap.gates.length === 0, gap };
282
+
283
+ const prerequisites: PrerequisiteHealth = {
284
+ ok: plan.missingPrerequisites.length === 0,
285
+ missing: plan.missingPrerequisites,
286
+ };
287
+
288
+ return {
289
+ state: "checked",
290
+ worker,
291
+ ok: config.ok && bindings.ok && migrations.ok && entitlements.ok && prerequisites.ok,
292
+ config,
293
+ bindings,
294
+ migrations,
295
+ entitlements,
296
+ prerequisites,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Build the project's health from one read-only reconcile plan per Worker. For each Worker, `config` fails
302
+ * when a capability's `pithy.config.ts` registration is missing manifest options; `bindings` fails when a
303
+ * required binding is absent from an environment; `migrations` fails when the target env has unapplied migrations **or** has applied one
304
+ * this Worker no longer declares; `entitlements` fails when a route gates on an entitlement no composed capability resolves;
305
+ * `prerequisites` fails when a composed capability declares a peer the Worker does not compose, which is
306
+ * the one that means the Worker will not start at all. The project is healthy only when every Worker is.
307
+ * Writes nothing — safe to run on every `pithy doctor` invocation.
308
+ */
309
+ export async function buildProjectHealth(options: ProjectHealthOptions): Promise<ProjectHealth> {
310
+ const build = options.buildPlan ?? defaultBuildPlan;
311
+ const scan = options.readManifests ?? availableManifests;
312
+
313
+ // Read once, at the project, because that is where manifests live: one install under the root's
314
+ // `node_modules/@pithy-sh`, shared by every Worker. Every plan below is built from the same scan, so a
315
+ // capability whose manifest will not read is missing from every Worker's checks at once — which is the
316
+ // hole this reports, and the reason it is not a per-Worker line.
317
+ const { faults } = await scan(options.projectDir);
318
+
319
+ // **One Worker at a time (#371).** The wiring is per Worker, so a failure to read it is per Worker too —
320
+ // and this is a diagnostic, so one Worker nobody could check must never cost the report on the others.
321
+ // The manifest scan above is not a contributor to this loop: it is read once, at the project, and every
322
+ // plan is built from it, which is why it is a project-wide line and why it still throws.
323
+ //
324
+ // The guard takes no binding. A plan reaches a customer's D1 and imports their config, so what it throws
325
+ // is throw-site context; the Worker's name is the actionable fact and `doctor` already prints it.
326
+ const workers: WorkerHealth[] = [];
327
+ for (const worker of options.workers) {
328
+ let plan: ReconcilePlan;
329
+ try {
330
+ plan = await build({
331
+ projectDir: options.projectDir,
332
+ workerDir: worker.dir,
333
+ worker: worker.name,
334
+ env: options.env,
335
+ account: options.account,
336
+ capabilities: worker.capabilities,
337
+ ...(worker.config ? { workerConfig: worker.config } : {}),
338
+ readLedger: options.readLedger,
339
+ });
340
+ } catch {
341
+ workers.push({ state: "unavailable", worker: worker.name });
342
+ continue;
343
+ }
344
+ workers.push(healthFromPlan(worker.name, plan));
345
+ }
346
+
347
+ const manifests: ManifestHealth = { ok: faults.length === 0, faults };
348
+ // An unchecked Worker fails the project, on the same standard #184 set for an unreadable manifest: a
349
+ // check that did not run established nothing, and a report calling a project healthy around a hole is
350
+ // the under-report this whole family exists to prevent. It is also what the behavior already was —
351
+ // the throw reached `pithy doctor`'s catch and drove a non-zero exit — so the gate does not weaken.
352
+ const checked = workers.every((worker) => worker.state === "checked" && worker.ok);
353
+ return { ok: manifests.ok && checked, workers, manifests };
354
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { HOST_WORKERS } from "../capabilities/hostRegistry";
6
+ import { hasCloudflareLogin as defaultHasCloudflareLogin, deliveryPreflight } from "../dev/delivery";
7
+
8
+ /**
9
+ * Whether local email delivery is **live** — the question `pithy doctor` answers about a machine, and
10
+ * the one nothing else in the report asks (pithy-sh/pithy#410).
11
+ *
12
+ * `Settings:` says whether the values work. Every other block says whether something is there. Neither
13
+ * says the thing a developer actually wants to know before they sit waiting on an inbox: does a magic
14
+ * link triggered from localhost leave this machine, or is it written to disk. That depends on a
15
+ * Cloudflare login and on the delivery mode the config selected, and both are readable here for free.
16
+ *
17
+ * **It is the same function `pithy dev` decides with.** {@link deliveryPreflight} returns the verdict
18
+ * and its wording, and this hands back exactly what it said — so doctor and the dev command cannot come
19
+ * to two answers about one project, and a rule that changes changes in one file. Which capability
20
+ * answers for delivery is the host registry's, not this module's: a tenth capability that puts
21
+ * something on the wire implements `delivery` and is reported here with no change.
22
+ *
23
+ * It is not a fault. The simulator is a legitimate choice — `email({ devDelivery: "simulator" })` picks
24
+ * it deliberately, and an offline machine has no other option — so this never fails the exit. It prints
25
+ * because silence would be read as "of course it sends".
26
+ */
27
+
28
+ /** What this machine would do with a message, and the words the run said it in. */
29
+ export interface LocalDeliveryCheck {
30
+ /** Whether a message sent from localhost actually leaves the machine. */
31
+ live: boolean;
32
+ /** The capability that answers for delivery — `email` today, the only one holding a send binding. */
33
+ capability: string;
34
+ /** The verdict verbatim: the same lines `pithy dev` prints in its ready banner, action included. */
35
+ lines: string[];
36
+ }
37
+
38
+ /** Everything the check reads, all of it injectable so a unit test never touches a credential file. */
39
+ export interface LocalDeliveryOptions {
40
+ projectDir: string;
41
+ /** The Workers in scope, with the capability instances doctor already resolved. */
42
+ workers: readonly { capabilities: readonly Capability[] }[];
43
+ /** Seam: whether Cloudflare credentials resolve at all. Defaults to the dev command's own reader. */
44
+ hasCloudflareLogin?: (projectDir: string, env: NodeJS.ProcessEnv) => Promise<boolean>;
45
+ env?: NodeJS.ProcessEnv;
46
+ }
47
+
48
+ /**
49
+ * The delivery verdict for this project, or `null` when nothing composed puts a message on the wire.
50
+ *
51
+ * `null` is the ordinary case and is silent: a project with no sending capability has no local delivery
52
+ * to be right or wrong about. A capability whose package will not load answers nothing rather than
53
+ * failing the report — the `Settings:` block reaches the same package on the same run and names it.
54
+ */
55
+ export async function checkLocalDelivery(options: LocalDeliveryOptions): Promise<LocalDeliveryCheck | null> {
56
+ const composed = new Map<string, Capability>();
57
+ for (const worker of options.workers) {
58
+ for (const capability of worker.capabilities) {
59
+ if (!composed.has(capability.name)) composed.set(capability.name, capability);
60
+ }
61
+ }
62
+
63
+ for (const spec of HOST_WORKERS) {
64
+ const capability = composed.get(spec.capability);
65
+ if (!capability || !spec.delivery) continue;
66
+ let identity: Awaited<ReturnType<NonNullable<typeof spec.delivery>>>;
67
+ try {
68
+ identity = await spec.delivery(capability);
69
+ } catch {
70
+ continue;
71
+ }
72
+ if (!identity) continue;
73
+ const preflight = deliveryPreflight({
74
+ composed: true,
75
+ requested: identity.requested,
76
+ ...(identity.fromAddress !== undefined ? { fromAddress: identity.fromAddress } : {}),
77
+ hasCloudflareLogin: await (options.hasCloudflareLogin ?? defaultHasCloudflareLogin)(
78
+ options.projectDir,
79
+ options.env ?? process.env,
80
+ ),
81
+ });
82
+ return { live: preflight.live, capability: spec.capability, lines: preflight.lines };
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /** The check as one sentence — the `--json` `detail`, and never a second wording of the lines. */
89
+ export function describeLocalDelivery(check: LocalDeliveryCheck): string {
90
+ return check.lines.join(" ").replace(/\s+/g, " ").trim();
91
+ }