@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,1483 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { basename, join } from "node:path";
6
+ import { type BindingSpec, BindingType, isProvisionedBinding } from "@pithy-sh/core/src/capability/bindings";
7
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
8
+ import {
9
+ type CapabilityManifest,
10
+ ConfigOption,
11
+ renderCapabilityRegistration,
12
+ renderConfigOptionComment,
13
+ renderConfigOptionLine,
14
+ } from "@pithy-sh/core/src/capability/manifest";
15
+ import { type PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
16
+ import { z } from "zod";
17
+ import type { CloudflareAccountSelection } from "../cloudflare/config";
18
+ import { type DatabaseRun, migrateProject, ProjectLedger, readProjectLedger } from "../migrations/run";
19
+ import {
20
+ appendBinding,
21
+ appendDurableObjectMigrations,
22
+ type BindingScope,
23
+ envStanzas,
24
+ stanzaHasBinding,
25
+ type WranglerStanza,
26
+ } from "../project/bindingEntries";
27
+ import { allCapabilities, loadWorkerConfig, readDeclinedBindings, type WorkerConfig } from "../project/config";
28
+ import { readOptionalFile } from "../project/readOptionalFile";
29
+ import { applyVersionMetadata, hasVersionMetadata } from "../project/versionMetadata";
30
+ import { workerIdentity } from "../project/workerIdentity";
31
+ import { readWranglerConfig, workerEntryPath, writeWranglerConfig } from "../project/wrangler";
32
+ import { declaresConstant } from "./configConstants";
33
+ import { exportsName } from "./configImports";
34
+ import { ejectedCapabilities } from "./eject";
35
+ import { findEntitlementGap } from "./entitlementGap";
36
+ import { durableObjectExports, withDurableObjectExports } from "./entryExports";
37
+ import { availableManifests } from "./manifests";
38
+ import { MissingPrerequisite, missingPrerequisites } from "./prerequisites";
39
+ import { requiredOptionRefusal } from "./requiredOptions";
40
+
41
+ /**
42
+ * The shared reconcile engine behind `pithy upgrade` and (read-only) `pithy doctor` — one plan-builder,
43
+ * two commands. {@link buildReconcilePlan} inspects **one Worker** and reports the drift between each
44
+ * capability's manifest and that Worker's `wrangler.jsonc` + `pithy.config.ts` without writing a byte;
45
+ * {@link applyReconcilePlan} is the write step `upgrade` runs against that plan. Doctor calls the builder
46
+ * alone and only renders it. Both commands fan the engine out over every Worker.
47
+ *
48
+ * **Two directories, deliberately distinct.** `workerDir` (`apps/<name>/`) is the *wiring*: the Worker's own
49
+ * `pithy.config.ts` and `wrangler.jsonc` — what a plan reads and an apply writes. `projectDir` (the repo root)
50
+ * is only where **manifests** resolve from, since packages install once into the root
51
+ * `node_modules/@pithy-sh/*` and every Worker shares them.
52
+ *
53
+ * **Installed is not composed.** Because that one install is shared, the manifests at the root are every
54
+ * capability installed *anywhere* in the project — not what this Worker is made of. A plan is therefore scoped
55
+ * to the Worker's own composed set (its `pithy.config.ts`): a capability another Worker added contributes
56
+ * nothing here. Anything else would put a foreign capability's bindings, and its Durable Object class
57
+ * migrations, on a script that never declared them.
58
+ */
59
+
60
+ /** A required binding a capability's manifest declares that a given environment's wrangler stanza lacks. */
61
+ export const MissingBinding = z
62
+ .object({
63
+ env: z
64
+ .string()
65
+ .describe('The environment missing the binding — "dev" for the top-level stanza, else the env.<name> key.'),
66
+ name: z.string().describe('The Worker env binding name the capability requires (e.g. "DB", "SESSIONS").'),
67
+ type: BindingType.describe(
68
+ "The kind of Cloudflare resource the binding refers to (d1, kv, r2, durable_object, …).",
69
+ ),
70
+ })
71
+ .describe("A required binding absent from one environment's wrangler.jsonc stanza.");
72
+ export type MissingBinding = z.infer<typeof MissingBinding>;
73
+
74
+ /** A binding an apply was asked to write and could not, with the reason the writer gave. */
75
+ export const SkippedBinding = MissingBinding.extend({
76
+ reason: z
77
+ .string()
78
+ .describe(
79
+ "Why the entry could not be composed, in an operator's words — the field the spec did not state, or the name that could not be derived.",
80
+ ),
81
+ }).describe(
82
+ "A required binding `pithy upgrade` could not write. Named rather than counted: reporting the plan instead of the write is what made `upgrade` claim five bindings it had declined, while `doctor` correctly still called them missing (#318).",
83
+ );
84
+ export type SkippedBinding = z.infer<typeof SkippedBinding>;
85
+
86
+ /**
87
+ * One entry in a Worker's `declinedBindings`, resolved against what it actually composes.
88
+ *
89
+ * **Four states, because "declined" is a claim that can be wrong in three distinct ways**, and each
90
+ * wants a different sentence from an operator surface. `honored` is the working case. `required` and
91
+ * `undeclinable` are refusals — an upgrade stops before it writes anything, because both mean the
92
+ * adopter believes a binding is being left out that is not. `unrecognized` is neither: nothing is
93
+ * being left out for it, which is worth a line and is never worth failing a project over, since
94
+ * `pithy remove <capability>` produces it and no command could then clear the red.
95
+ */
96
+ export const BindingDecline = z
97
+ .discriminatedUnion("state", [
98
+ z
99
+ .object({
100
+ state: z.literal("honored").describe("The decline is being applied — this binding is left out."),
101
+ name: z.string().describe("The binding name, as the adopter wrote it and as the capability declares it."),
102
+ type: BindingType.describe("The kind of Cloudflare resource the declined binding refers to."),
103
+ capability: z.string().describe("The composed capability that declares this binding as optional."),
104
+ reason: z.string().describe("The adopter's own reason, printed back verbatim by `pithy doctor`."),
105
+ stillPresentIn: z
106
+ .array(z.string())
107
+ .describe(
108
+ "Environments whose wrangler.jsonc still carries a stanza for it — written by an upgrade that ran before the decline. Declining stops the binding being re-added; it never deletes what is already there, because removing a binding an adopter may still be pointing at is not a reporting command's decision.",
109
+ ),
110
+ })
111
+ .describe("A decline this Worker's composition supports, and which upgrade is applying."),
112
+ z
113
+ .object({
114
+ state: z.literal("required").describe("The binding is not optional — the decline is refused."),
115
+ name: z.string().describe("The binding name the adopter declined."),
116
+ type: BindingType.describe("The kind of Cloudflare resource it refers to."),
117
+ capability: z.string().describe("A composed capability that requires this binding outright."),
118
+ reason: z.string().describe("The adopter's stated reason, carried so the refusal can quote the line."),
119
+ })
120
+ .describe(
121
+ "A decline of a binding some composed capability requires. Refused: `optional` is the capability's own statement that its code has a path for the absence, and a required binding has none — leaving it out is a boot failure, not a configuration.",
122
+ ),
123
+ z
124
+ .object({
125
+ state: z.literal("undeclinable").describe("The binding's kind cannot be declined — the decline is refused."),
126
+ name: z.string().describe("The binding name the adopter declined."),
127
+ type: BindingType.describe("The kind that cannot be declined: workflow or durable_object."),
128
+ capability: z.string().describe("The composed capability that declares it."),
129
+ reason: z.string().describe("The adopter's stated reason, carried so the refusal can quote the line."),
130
+ })
131
+ .describe(
132
+ "A decline of a kind where `optional` does not mean what it means elsewhere. For a Workflow, absent means *not provisioned yet* — `pithy <capability> provision` is the fix, and declining it only hides the instruction. For a Durable Object it is worse: a class migration tag is written once and never revisited, so a decline that arrives after one upgrade cannot undo what an earlier one stamped.",
133
+ ),
134
+ z
135
+ .object({
136
+ state: z.literal("unrecognized").describe("Nothing this Worker composes declares this binding."),
137
+ name: z.string().describe("The binding name the adopter declined."),
138
+ reason: z.string().describe("The adopter's stated reason, carried so the line can quote it."),
139
+ })
140
+ .describe(
141
+ "A decline naming a binding no composed capability declares. Reported, never fatal: `pithy remove <capability>` leaves exactly this state behind, and a CLI that failed on it would create a red no command could clear.",
142
+ ),
143
+ ])
144
+ .describe("One `declinedBindings` entry, resolved against what this Worker composes.");
145
+ export type BindingDecline = z.infer<typeof BindingDecline>;
146
+
147
+ /**
148
+ * A Worker's whole `declinedBindings` declaration, or the fact that it could not be read.
149
+ *
150
+ * The same two-state shape as {@link EntitlementGap} and the ledger, for the same reason: a declaration
151
+ * that does not parse is neither "declines nothing" nor a crash, and a count cannot say so. `pithy
152
+ * doctor` reports it; `pithy upgrade` refuses on it before writing.
153
+ */
154
+ export const BindingDeclines = z
155
+ .discriminatedUnion("state", [
156
+ z
157
+ .object({
158
+ state: z.literal("read").describe("The declaration parsed."),
159
+ declines: z.array(BindingDecline).describe("Every entry, resolved. Empty is the ordinary case."),
160
+ })
161
+ .describe("A `declinedBindings` declaration that parsed, with each entry resolved."),
162
+ z
163
+ .object({
164
+ state: z.literal("invalid").describe("The declaration is present and malformed."),
165
+ problem: z
166
+ .string()
167
+ .describe("What is wrong with it, naming the entry — an operator's sentence, not a Zod dump."),
168
+ })
169
+ .describe("A `declinedBindings` declaration that is present and cannot be read."),
170
+ ])
171
+ .describe(
172
+ "This Worker's declined optional bindings, resolved against its composition — or the fact that the declaration could not be read.",
173
+ );
174
+ export type BindingDeclines = z.infer<typeof BindingDeclines>;
175
+
176
+ /**
177
+ * Whether a binding of this kind may never be declined.
178
+ *
179
+ * **Two rules, and only one of them is hand-written.** A *provisioned* kind — `secret`, `workflow`,
180
+ * `vectorize` — is one whose resource exists only after `pithy <capability> provision`, so `optional`
181
+ * there means *not provisioned yet*, never *not wanted*: declining it suppresses the very binding the
182
+ * provision command exists to supply, and hides the instruction that would have fixed it. That set is
183
+ * asked of {@link isProvisionedBinding} rather than copied, because a copy is a list that stops
184
+ * agreeing the day a kind joins it — the first draft here listed only `workflow` and silently admitted
185
+ * declines of the other two.
186
+ *
187
+ * A Durable Object is refused for a different reason and so is named separately:
188
+ * `appendDurableObjectMigrations` writes a `new_sqlite_classes` tag once and never revisits a class
189
+ * already named by one, so a decline arriving after an upgrade cannot undo that upgrade's stamp and
190
+ * would leave the tag and the binding disagreeing permanently. Un-stamping one is how a Durable Object
191
+ * loses its storage, so there is no repair to offer either.
192
+ */
193
+ function isUndeclinableKind(type: BindingType): boolean {
194
+ return isProvisionedBinding(type) || type === "durable_object";
195
+ }
196
+
197
+ /** The sentence that says what a kind's absence actually means, for an operator reading a refusal. */
198
+ export function undeclinableReason(type: BindingType): string {
199
+ if (type === "durable_object") {
200
+ return "A Durable Object's class migration tag is written once and never revisited, so this cannot be taken back later.";
201
+ }
202
+ return "Absent means not provisioned.";
203
+ }
204
+
205
+ /** A capability config option present in the manifest but not yet written into its pithy.config.ts call. */
206
+ export const MissingConfigKey = z
207
+ .object({
208
+ // Every field is the manifest's own contract, not a copy of one. The copied `default` was a scalar
209
+ // union, so an option whose value is a literal only the adopter can fill in — the one `pithy add
210
+ // secrets` could not render at all (#161) — was the one option `pithy upgrade` could not report as
211
+ // missing either. That value is now a whole worked example (#168), which is exactly why it is
212
+ // rendered through the manifest's own `renderConfigOptionLine` rather than by anything this file
213
+ // writes itself (#171). `key` and `describe` were still bare `z.string()` here after #174 narrowed
214
+ // them at the manifest, which is the same copy waiting to drift the same way.
215
+ key: ConfigOption.shape.key.describe(
216
+ "The option name to add to the capability's registration call in pithy.config.ts.",
217
+ ),
218
+ default: ConfigOption.shape.default.describe(
219
+ "The manifest default rendered as the option's value (an adopter can change it afterward). Absent for a **required** option — one the manifest states no default for, because the answer is the adopter's. The key is still reported, so `pithy doctor` names the drift; what `pithy upgrade` cannot do is choose a value for it.",
220
+ ),
221
+ choices: ConfigOption.shape.choices.describe(
222
+ "The closed set this option takes, when it states one — carried so a refusal to write a required key can name the legal values rather than only the key.",
223
+ ),
224
+ describe: ConfigOption.shape.describe.describe(
225
+ "The option's rationale, rendered as the comment above it in pithy.config.ts.",
226
+ ),
227
+ constant: ConfigOption.shape.constant.describe(
228
+ "Present when this option is written as one of the scaffolded config's constants — `publicOrigin` renders as `PUBLIC_ORIGIN` — instead of as `default`. Only when this Worker's pithy.config.ts declares it; an older project keeps the literal rather than being handed an identifier nothing defines.",
229
+ ),
230
+ })
231
+ .describe("A manifest config option not yet present in the capability's pithy.config.ts registration.");
232
+ export type MissingConfigKey = z.infer<typeof MissingConfigKey>;
233
+
234
+ /** One installed, non-ejected capability's drift: the bindings and config keys its manifest adds beyond the project. */
235
+ export const CapabilityReconcile = z
236
+ .object({
237
+ name: z.string().describe("The capability's short name (its manifest name)."),
238
+ missingBindings: z
239
+ .array(MissingBinding)
240
+ .describe("Required bindings absent from one or more environments' wrangler.jsonc stanzas."),
241
+ missingConfigKeys: z
242
+ .array(MissingConfigKey)
243
+ .describe("Manifest config options not yet present in this capability's pithy.config.ts registration."),
244
+ missingEntryExports: z
245
+ .array(z.string())
246
+ .describe(
247
+ "Durable Object classes this capability binds in this Worker that the module its `main` names does not export. wrangler resolves `class_name` against that module and refuses the deploy without it, so this is drift a `wrangler.jsonc` read alone cannot see: the binding is there and the class is nowhere. An upgrade writes them.",
248
+ ),
249
+ })
250
+ .describe("The reconcile drift for a single installed, non-ejected capability.");
251
+ export type CapabilityReconcile = z.infer<typeof CapabilityReconcile>;
252
+
253
+ /**
254
+ * Whether this Worker's routes gate on an entitlement nothing composed provides — or that the scan could
255
+ * not run (#371).
256
+ *
257
+ * The scan reads the Worker's own source tree, so it fails the way a directory fails: a permission, a
258
+ * symlink loop, a tree that moved mid-read. An empty `gates` array was the answer for both "no gate" and
259
+ * "no scan", and only one of those is good news — so the list lives behind `read` and a caller reaches it
260
+ * by narrowing.
261
+ */
262
+ export const EntitlementGap = z
263
+ .discriminatedUnion("state", [
264
+ z
265
+ .object({
266
+ state: z.literal("read").describe("The Worker's source tree was scanned."),
267
+ gates: z
268
+ .array(z.string())
269
+ .describe(
270
+ "This Worker's own source files that gate a route on an entitlement while nothing it composes provides one, relative to its directory. Empty means no gap — the healthy state.",
271
+ ),
272
+ })
273
+ .describe("The scan's answer. An empty list here is a real answer and means there is no gap."),
274
+ z
275
+ .object({
276
+ state: z.literal("unavailable").describe("The Worker's source tree could not be scanned."),
277
+ })
278
+ .describe(
279
+ "No scan was made, so nothing is known about this Worker's gates. Deliberately empty: nothing derived from the failure travels, and there is no empty list here to mistake for no gap.",
280
+ ),
281
+ ])
282
+ .describe(
283
+ "The entitlement-composition gap for one Worker, or that it could not be looked for. The file list sits behind the discriminant, so `no gap` and `no scan` cannot be confused.",
284
+ );
285
+ export type EntitlementGap = z.infer<typeof EntitlementGap>;
286
+
287
+ /** The read-only reconcile plan: what an upgrade would add, what it would skip, and how far the schema is behind. */
288
+ export const ReconcilePlan = z
289
+ .object({
290
+ worker: z.string().describe("The Worker this plan targets, as its apps/<name> directory — what --worker accepts."),
291
+ deployedAs: z
292
+ .string()
293
+ .describe("The same Worker's deployed script name, from wrangler.jsonc — what the Cloudflare dashboard shows."),
294
+ env: z.string().describe("The environment the pending-migration count was computed for."),
295
+ perCapability: z
296
+ .array(CapabilityReconcile)
297
+ .describe("Per installed, non-ejected capability: the bindings and config keys an upgrade would add."),
298
+ ejectedSkipped: z
299
+ .array(z.string())
300
+ .describe("Ejected capabilities, by name — never reconciled, since ejected code no longer tracks its package."),
301
+ ledger: ProjectLedger.describe(
302
+ "What `env`'s databases have applied against what this Worker declares, in both directions — unapplied migrations (which an upgrade applies with --migrate) and migrations the ledger records that nothing declares any more (which it cannot: whether to restore the migration or drop its ledger row depends on what the database holds, so it is report-only). It is the ledger's own four-way value rather than two flat fields, because a database that could not be read is neither of those things and a count alone cannot say so (#282, #371).",
303
+ ),
304
+ entitlements: EntitlementGap.describe(
305
+ "Whether this Worker gates a route on an entitlement while nothing it composes provides one — and whether the question could be asked at all. Report-only: an upgrade cannot fix it, because which capability to compose is the adopter's decision.",
306
+ ),
307
+ missingPrerequisites: z
308
+ .array(MissingPrerequisite)
309
+ .describe(
310
+ "Capabilities this Worker composes whose manifest declares a peer it does not compose. The Worker will not assemble: createBackend refuses on exactly this pair, so it is a boot failure rather than drift. Report-only, like the entitlement gap — composing a capability is the adopter's decision, and `pithy add <cap> --with-prerequisites` is the command that makes it.",
311
+ ),
312
+ declinedBindings: BindingDeclines.describe(
313
+ "Optional bindings this Worker's pithy.config.ts declines, each resolved against what it composes. An honored decline is left out of wrangler.jsonc by an upgrade and reported as declined rather than missing by doctor; a refused one stops an upgrade before it writes. It rides the plan because `applyReconcilePlan` re-reads nothing — plan and write must be one decision, which is what #318 cost when they were two.",
314
+ ),
315
+ missingVersionMetadata: z
316
+ .boolean()
317
+ .describe(
318
+ "Whether this Worker's wrangler.jsonc lacks the `version_metadata` binding named `CF_VERSION_METADATA`. Without it a Worker cannot report which build is running, so log records carry no `version`, audit events carry no build id, and `pithy deploy` cannot verify the deploy it just made. An upgrade adds it; a config naming a different binding is reported and left alone.",
319
+ ),
320
+ })
321
+ .describe(
322
+ "A read-only reconcile plan for one Worker: binding/config drift, ejected skips, pending migrations, and the entitlement composition gap.",
323
+ );
324
+ export type ReconcilePlan = z.infer<typeof ReconcilePlan>;
325
+
326
+ /** The one Worker a migration seam runs against, plus the project root its local D1 state lives under. */
327
+ export interface MigrationScope {
328
+ /** The project root — the owner of the shared `.wrangler/state` store, not a source of wiring. */
329
+ projectDir: string;
330
+ /** The Worker's directory — its `wrangler.jsonc` supplies the D1 bindings and their ids. */
331
+ workerDir: string;
332
+ /** The Worker's name, so the migration run reports against it. */
333
+ worker: string;
334
+ /** The environment to run against. */
335
+ env: string;
336
+ /**
337
+ * The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`, or
338
+ * `null` when it names none. Required (#234): `pithy upgrade --migrate --env staging` reaches a real
339
+ * D1 through this scope, and the account it reaches it in is the project's, never the default file's.
340
+ */
341
+ account: CloudflareAccountSelection | null;
342
+ /** The Worker's composed capabilities — the migration registry. */
343
+ capabilities: Capability[];
344
+ }
345
+
346
+ /**
347
+ * A scope that will **write**: the same Worker, plus the project every database it touches is claimed
348
+ * for. Separate from {@link MigrationScope} because the read side is not the write side — `pithy doctor`
349
+ * counts pending migrations on a project that may have no `name` yet, while `pithy upgrade --migrate`
350
+ * must name itself or leave a database unowned for the next project to adopt.
351
+ */
352
+ export interface MigrateScope extends MigrationScope {
353
+ /** The project name (root `pithy.config.ts` `name`, via `requireProjectName`) each database is claimed for. */
354
+ project: string;
355
+ }
356
+
357
+ /**
358
+ * Read one Worker's migration ledger for an environment — the migration seam, injectable for tests.
359
+ *
360
+ * It used to return a number, and that number was the whole of what any caller could learn: how many
361
+ * declared migrations had not run. An applied migration the project no longer declares is invisible to
362
+ * that subtraction and is the state that stops `pithy migrate` dead, so the seam returns the comparison
363
+ * rather than one side of it (#282).
364
+ */
365
+ export type ReadLedger = (options: MigrationScope) => Promise<ProjectLedger>;
366
+
367
+ /** Run one Worker's migrations for an environment — the apply-time migration seam, injectable for tests. */
368
+ export type RunMigrate = (options: MigrateScope) => Promise<DatabaseRun[]>;
369
+
370
+ // The migration entry points fan out over `apps/` themselves; reconcile is already inside that fan-out, so it
371
+ // hands them the single pre-resolved Worker it is reconciling. `projectDir` stays the project root — the local
372
+ // Miniflare store lives at `<root>/.wrangler/state`, shared with `wrangler dev`, never per Worker.
373
+ function scopeFor({ projectDir, workerDir, worker, env, capabilities, account }: MigrationScope) {
374
+ return { projectDir, env, account, workers: [{ name: worker, dir: workerDir, capabilities }] };
375
+ }
376
+
377
+ const defaultReadLedger: ReadLedger = (scope) => readProjectLedger(scopeFor(scope));
378
+
379
+ const defaultRunMigrate: RunMigrate = async (scope) =>
380
+ (await migrateProject({ ...scopeFor(scope), project: scope.project })).flatMap((run) => run.databases);
381
+
382
+ /** Options for {@link buildReconcilePlan}. `capabilities` may be passed to skip loading (and executing) pithy.config.ts. */
383
+ export interface BuildReconcilePlanOptions {
384
+ /** The project root — only where `node_modules/@pithy-sh/*` (and so every capability manifest) resolves from. */
385
+ projectDir: string;
386
+ /** The Worker's directory (`apps/<name>/`) — the wiring: its own pithy.config.ts and wrangler.jsonc. */
387
+ workerDir: string;
388
+ /**
389
+ * The Worker's **deployed** script name, from its `wrangler.jsonc`. Reported on the plan as
390
+ * `deployedAs`; the plan's `worker` is always `workerDir`'s basename and is never taken from here.
391
+ * Defaults to the basename when a caller has no config to read.
392
+ */
393
+ worker?: string;
394
+ /** The environment the pending-migration count is computed for. Binding drift is reported across every environment regardless. */
395
+ env: string;
396
+ /**
397
+ * The Worker's composed capabilities (libraries + app) — **the scope of the whole plan**, not just its
398
+ * migration count. Only a capability named here is reconciled; one installed at the project root for
399
+ * another Worker contributes nothing. Loaded from this Worker's own `pithy.config.ts` when omitted.
400
+ */
401
+ capabilities?: Capability[];
402
+ /**
403
+ * The Cloudflare account this project belongs to, or `null` when it names none. Reaches the plan's one
404
+ * network-capable step, the pending-migration count, and nothing else (#234).
405
+ */
406
+ account: CloudflareAccountSelection | null;
407
+ /** Test seam: read the migration ledger without a real Miniflare/D1 run. */
408
+ readLedger?: ReadLedger;
409
+ /**
410
+ * Test seam: find the entitlement gap without a source tree that refuses to read.
411
+ *
412
+ * It exists for the same reason `readLedger` does. The walk behind {@link findEntitlementGap} is
413
+ * deliberately hard to make throw — `ci/sourceFiles.ts` treats a directory it cannot list as skipped —
414
+ * and a guard nobody can drive is a guard nobody can prove. #371's gate on this contributor plants the
415
+ * throw through here.
416
+ */
417
+ findGap?: (workerDir: string, capabilities: readonly Capability[]) => Promise<string[]>;
418
+ /**
419
+ * This Worker's own `pithy.config.ts`, as its caller already loaded it.
420
+ *
421
+ * Only `declinedBindings` is read from it, and **the whole object rather than that one field on
422
+ * purpose**: {@link readDeclinedBindings} also refuses a key that is nearly `declinedBindings`, and
423
+ * it can only see such a key if it is handed the object the adopter actually wrote. Passing the
424
+ * field alone made that check unreachable — every real config arrived as a synthetic two-key object
425
+ * and `declined_bindings` sailed through, which is the silence this whole feature removes.
426
+ *
427
+ * It travels beside `capabilities` rather than being re-read here for the same reason `capabilities`
428
+ * does: both callers already hold the loaded config, and reading it twice is how the two commands
429
+ * would come to disagree about one Worker.
430
+ */
431
+ workerConfig?: WorkerConfig;
432
+ }
433
+
434
+ /** Escape a capability name for use inside a `RegExp`. */
435
+ function escapeRegExp(text: string): string {
436
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
437
+ }
438
+
439
+ /**
440
+ * The bindings a capability actually needs *in this Worker*.
441
+ *
442
+ * A manifest is one static file and cannot vary with config, so a binding a capability needs only under
443
+ * a particular setting is declared there as `optional` — the seam's `CONTROL_PLANE` KV, which nothing
444
+ * reads under the default `d1` replay backend.
445
+ *
446
+ * **The manifest decides what is required; the composed instance decides whether an optional one is
447
+ * needed here.** Non-optional entries always apply — that is what the manifest is for, and it is the
448
+ * only source available to `pithy add`, which runs before any config exists. An optional entry is
449
+ * included only when this Worker's *composed* capability genuinely requires it, which is knowable
450
+ * because `controlplane({ replayBackend: "kv" })` pushes the KV binding, non-optional, exactly when it
451
+ * is needed.
452
+ *
453
+ * Both halves matter. Skipping optional bindings outright left an adopter who selected `kv` with no
454
+ * binding written by any CLI path and a hard assembly failure at boot. Taking the composed instance's
455
+ * list wholesale instead would make the manifest dead weight and would report nothing at all for a
456
+ * caller that passes a name-only capability marker.
457
+ *
458
+ * **`declined` is the third source, and the adopter's.** A capability can only say a binding is optional;
459
+ * it cannot know that this Worker's account has no R2. Only the `optional` half is filtered, so a
460
+ * decline can never remove a binding the composition genuinely requires.
461
+ *
462
+ * The parameter is required and undefaulted **on purpose**. This is the single function every consumer
463
+ * resolves `optional` through — the plan, the entry-export check, the stanza writer, the Durable Object
464
+ * migration tagger, and the entry-export writer — and #318 was exactly the shape where one of them
465
+ * answered from a different list than the others. A sixth consumer must be a compile error, not a
466
+ * silent write.
467
+ */
468
+ function effectiveBindings(
469
+ manifest: CapabilityManifest,
470
+ composed: Capability | undefined,
471
+ declined: ReadonlySet<string>,
472
+ ): BindingSpec[] {
473
+ const required = manifest.requiredBindings.filter((binding) => !binding.optional);
474
+ const optional = manifest.requiredBindings.filter((binding) => binding.optional);
475
+ if (optional.length === 0) return required;
476
+
477
+ const needed = new Set((composed?.requiredBindings ?? []).map((binding) => `${binding.type}:${binding.name}`));
478
+ return [
479
+ ...required,
480
+ ...optional.filter((binding) => needed.has(`${binding.type}:${binding.name}`) && !declined.has(binding.name)),
481
+ ];
482
+ }
483
+
484
+ /**
485
+ * The binding names an upgrade is actually leaving out, from a plan.
486
+ *
487
+ * One helper rather than a filter at each apply-side call site, so three writers cannot each decide
488
+ * what "declined" means. Only `honored` entries count: a refused decline is refused everywhere, and an
489
+ * unrecognized one names nothing to leave out.
490
+ */
491
+ /**
492
+ * Resolve a Worker's declared declines against the capabilities it actually composes.
493
+ *
494
+ * Every entry lands in exactly one of {@link BindingDecline}'s four states, decided in this order:
495
+ *
496
+ * 1. **`required`** if *any* composed capability declares the binding non-optionally. Any, not all:
497
+ * two capabilities may share a binding name — that is how Workers share one D1 — and a binding one
498
+ * of them needs outright is not declinable because another calls it optional.
499
+ * 2. **`undeclinable`** if the binding's kind is one where `optional` does not mean "not wanted".
500
+ * 3. **`honored`** if some composed capability declares it optionally.
501
+ * 4. **`unrecognized`** otherwise. Nothing is being left out for it.
502
+ *
503
+ * Sorted by name so two runs of `pithy doctor` over one unchanged project print the same report — a
504
+ * `--json` consumer diffing two runs must not see a reordering as a change.
505
+ */
506
+ function resolveDeclines(
507
+ declared: Record<string, string>,
508
+ manifests: readonly CapabilityManifest[],
509
+ composed: ReadonlySet<string>,
510
+ ejected: readonly string[],
511
+ stanzas: readonly { env: string; stanza: WranglerStanza }[],
512
+ instances: ReadonlyMap<string, Capability>,
513
+ ): BindingDecline[] {
514
+ // Only what this Worker composes and has not ejected — the same scope the rest of the plan uses.
515
+ // An ejected capability's code no longer tracks its package, so its manifest says nothing about
516
+ // what this Worker binds.
517
+ // **Sorted, because the capability named in the report is chosen from this order.** `manifests` comes
518
+ // from an unsorted `readdir`, and fifteen shipped capabilities declare `d1 DB` — so declining it named
519
+ // `auth` on one machine and `media` on another, in the terminal and in `--json` alike. The decision is
520
+ // the same either way; the sentence must not be, or two runs of one unchanged project disagree.
521
+ const relevant = manifests
522
+ .filter((manifest) => composed.has(manifest.name) && !ejected.includes(manifest.name))
523
+ .slice()
524
+ .sort((a, b) => a.name.localeCompare(b.name));
525
+ return Object.entries(declared)
526
+ .sort(([a], [b]) => a.localeCompare(b))
527
+ .map(([name, reason]): BindingDecline => {
528
+ const declarers = relevant.flatMap((manifest) =>
529
+ manifest.requiredBindings.filter((binding) => binding.name === name).map((binding) => ({ manifest, binding })),
530
+ );
531
+ // **Both sources, and either one is enough to refuse.** `effectiveBindings` states the rule this
532
+ // follows: the manifest says what is required everywhere, and the composed instance says what is
533
+ // required *here*. `@pithy-sh/core` is the live case — its manifest marks `CONTROL_PLANE`
534
+ // optional, because a manifest is one static file and cannot vary with config, while
535
+ // `controlplane({ replayBackend: "kv" })` pushes the same binding non-optionally, because under
536
+ // that setting the replay guard reads it on every request and there is no absence path at all.
537
+ // Reading the manifest alone resolved that decline as `honored` and had `pithy doctor` print
538
+ // "takes its optional path" about a Worker that would 500 on every control-plane route.
539
+ const requiredBy =
540
+ declarers.find(({ binding }) => !binding.optional) ??
541
+ declarers.find(({ manifest }) =>
542
+ (instances.get(manifest.name)?.requiredBindings ?? []).some(
543
+ (binding) => binding.name === name && !binding.optional,
544
+ ),
545
+ );
546
+ if (requiredBy) {
547
+ return {
548
+ state: "required",
549
+ name,
550
+ type: requiredBy.binding.type,
551
+ capability: requiredBy.manifest.name,
552
+ reason,
553
+ };
554
+ }
555
+ const optionalBy = declarers[0];
556
+ if (!optionalBy) return { state: "unrecognized", name, reason };
557
+ if (isUndeclinableKind(optionalBy.binding.type)) {
558
+ return {
559
+ state: "undeclinable",
560
+ name,
561
+ type: optionalBy.binding.type,
562
+ capability: optionalBy.manifest.name,
563
+ reason,
564
+ };
565
+ }
566
+ return {
567
+ state: "honored",
568
+ name,
569
+ type: optionalBy.binding.type,
570
+ capability: optionalBy.manifest.name,
571
+ reason,
572
+ // Computed from the stanzas already read, so declining tells the adopter what an earlier
573
+ // upgrade left behind rather than leaving them to find it. It is reported, never deleted:
574
+ // removing a binding they may still be pointing at is not a reporting command's decision.
575
+ stillPresentIn: stanzas
576
+ .filter(({ stanza }) => stanzaHasBinding(stanza, optionalBy.binding) === true)
577
+ .map(({ env }) => env),
578
+ };
579
+ });
580
+ }
581
+
582
+ /**
583
+ * The binding names a Worker is leaving out, resolved from its config against what it composes.
584
+ *
585
+ * **The one export of this rule, for callers that hold no plan.** `pithy provision` and `pithy feature
586
+ * create` create the resource behind each provisionable binding, and a decline that stopped `pithy
587
+ * upgrade` writing a binding while provisioning still made the bucket handed the adopter exactly the
588
+ * resource they had declined (#440). They reach the same {@link resolveDeclines} the plan does rather
589
+ * than re-deciding what "declined" means — a second expression of a four-state rule is how two commands
590
+ * come to disagree, which is this issue one level up.
591
+ *
592
+ * No stanzas, because `stillPresentIn` is a fact about a `wrangler.jsonc` and provisioning is not
593
+ * reading one; the field is empty here and nothing consumes it. Only `honored` names come back, so a
594
+ * refused decline changes nothing about what is provisioned — exactly as it changes nothing about what
595
+ * is written.
596
+ */
597
+ export function honoredDeclineNames(options: {
598
+ /** Every installed capability manifest, read once by the caller — this is called per Worker. */
599
+ manifests: readonly CapabilityManifest[];
600
+ /** That Worker's composed capabilities. */
601
+ capabilities: readonly Capability[];
602
+ /** That Worker's own `pithy.config.ts`. Absent means it declines nothing. */
603
+ workerConfig?: WorkerConfig | undefined;
604
+ /** Capabilities this Worker has ejected, whose manifests no longer describe its wiring. */
605
+ ejected?: readonly string[];
606
+ }): ReadonlySet<string> {
607
+ const read = readDeclinedBindings(options.workerConfig ?? { capabilities: [] });
608
+ if (read.state === "invalid") return new Set();
609
+ const composed = new Set(options.capabilities.map((capability) => capability.name));
610
+ const byName = new Map(options.capabilities.map((capability) => [capability.name, capability]));
611
+ const declines = resolveDeclines(read.declared, options.manifests, composed, options.ejected ?? [], [], byName);
612
+ return new Set(declines.filter((decline) => decline.state === "honored").map((decline) => decline.name));
613
+ }
614
+
615
+ function honoredDeclines(plan: ReconcilePlan): ReadonlySet<string> {
616
+ if (plan.declinedBindings.state !== "read") return new Set();
617
+ return new Set(
618
+ plan.declinedBindings.declines.filter((decline) => decline.state === "honored").map((decline) => decline.name),
619
+ );
620
+ }
621
+
622
+ /** Every required binding absent from an environment, across every environment. Unsupported kinds are skipped. */
623
+ function computeMissingBindings(
624
+ manifest: CapabilityManifest,
625
+ stanzas: { env: string; stanza: WranglerStanza }[],
626
+ composed: Capability | undefined,
627
+ declined: ReadonlySet<string>,
628
+ ): MissingBinding[] {
629
+ const missing: MissingBinding[] = [];
630
+ for (const binding of effectiveBindings(manifest, composed, declined)) {
631
+ for (const { env, stanza } of stanzas) {
632
+ const has = stanzaHasBinding(stanza, binding);
633
+ if (has === null) break; // unsupported kind — the same for every env, skip it entirely
634
+ if (!has) missing.push({ env, name: binding.name, type: binding.type });
635
+ }
636
+ }
637
+ return missing;
638
+ }
639
+
640
+ /** A located capability registration call in pithy.config.ts source. */
641
+ type RegistrationLocation =
642
+ | { form: "oneliner"; indent: string; presentKeys: string[] }
643
+ | { form: "block"; indent: string; presentKeys: string[]; closeIndex: number };
644
+
645
+ /** From a `{`, the index of its matching `}` — string- and comment-aware so braces in strings/comments don't miscount. */
646
+ function matchBrace(source: string, openIndex: number): number {
647
+ let depth = 0;
648
+ let inString = false;
649
+ let quote = "";
650
+ for (let i = openIndex; i < source.length; i++) {
651
+ const ch = source[i];
652
+ if (inString) {
653
+ if (ch === "\\") {
654
+ i++;
655
+ continue;
656
+ }
657
+ if (ch === quote) inString = false;
658
+ continue;
659
+ }
660
+ if (ch === '"' || ch === "'" || ch === "`") {
661
+ inString = true;
662
+ quote = ch;
663
+ continue;
664
+ }
665
+ if (ch === "/" && source[i + 1] === "/") {
666
+ const nl = source.indexOf("\n", i);
667
+ if (nl === -1) return -1;
668
+ i = nl;
669
+ continue;
670
+ }
671
+ if (ch === "/" && source[i + 1] === "*") {
672
+ const end = source.indexOf("*/", i + 2);
673
+ if (end === -1) return -1;
674
+ i = end + 1;
675
+ continue;
676
+ }
677
+ if (ch === "{") depth++;
678
+ else if (ch === "}") {
679
+ depth--;
680
+ if (depth === 0) return i;
681
+ }
682
+ }
683
+ return -1;
684
+ }
685
+
686
+ /** True when the next non-whitespace character at or after `from` is a `:` — i.e. the token before it is a key. */
687
+ function followedByColon(body: string, from: number): boolean {
688
+ let k = from;
689
+ while (k < body.length && /\s/.test(body[k] as string)) k++;
690
+ return body[k] === ":";
691
+ }
692
+
693
+ /**
694
+ * The top-level object keys in a registration body — string- and comment-aware (line and block comments),
695
+ * recognizing both bare identifier keys (`basePath:`) and quoted keys (`"base-path":`, `'x':`). Scalars-only
696
+ * bodies. A quoted key is returned unquoted, so it compares equal to the manifest option name.
697
+ */
698
+ function objectKeys(body: string): string[] {
699
+ const keys: string[] = [];
700
+ let i = 0;
701
+ let depth = 0;
702
+ while (i < body.length) {
703
+ const ch = body[i];
704
+ if (ch === '"' || ch === "'" || ch === "`") {
705
+ // Consume the whole string, then — at depth 0 and followed by `:` — record it as a quoted key.
706
+ const quote = ch;
707
+ let j = i + 1;
708
+ while (j < body.length && body[j] !== quote) {
709
+ if (body[j] === "\\") j += 2;
710
+ else j++;
711
+ }
712
+ if (depth === 0 && followedByColon(body, j + 1)) keys.push(body.slice(i + 1, j));
713
+ i = j + 1;
714
+ continue;
715
+ }
716
+ if (ch === "/" && body[i + 1] === "/") {
717
+ const nl = body.indexOf("\n", i);
718
+ if (nl === -1) break;
719
+ i = nl + 1;
720
+ continue;
721
+ }
722
+ if (ch === "/" && body[i + 1] === "*") {
723
+ const end = body.indexOf("*/", i + 2);
724
+ if (end === -1) break;
725
+ i = end + 2;
726
+ continue;
727
+ }
728
+ if (ch === "{" || ch === "[" || ch === "(") {
729
+ depth++;
730
+ i++;
731
+ continue;
732
+ }
733
+ if (ch === "}" || ch === "]" || ch === ")") {
734
+ depth--;
735
+ i++;
736
+ continue;
737
+ }
738
+ if (depth === 0 && ch !== undefined && /[A-Za-z_$]/.test(ch)) {
739
+ let j = i + 1;
740
+ while (j < body.length && /[\w$]/.test(body[j] as string)) j++;
741
+ if (followedByColon(body, j)) keys.push(body.slice(i, j));
742
+ i = j;
743
+ continue;
744
+ }
745
+ i++;
746
+ }
747
+ return keys;
748
+ }
749
+
750
+ /** Find a capability's registration call in pithy.config.ts source, and which option keys it already carries. */
751
+ export function locateRegistration(source: string, name: string): RegistrationLocation | null {
752
+ const re = new RegExp(`^([ \\t]*)${escapeRegExp(name)}[ \\t]*\\(`, "m");
753
+ const match = re.exec(source);
754
+ if (!match) return null;
755
+ const indent = match[1] ?? "";
756
+ const parenIndex = match.index + match[0].length - 1;
757
+ let i = parenIndex + 1;
758
+ while (i < source.length && /\s/.test(source[i] as string)) i++;
759
+ const ch = source[i];
760
+ if (ch === ")") return { form: "oneliner", indent, presentKeys: [] };
761
+ if (ch === "{") {
762
+ const closeIndex = matchBrace(source, i);
763
+ if (closeIndex === -1) return null;
764
+ return { form: "block", indent, presentKeys: objectKeys(source.slice(i + 1, closeIndex)), closeIndex };
765
+ }
766
+ return null;
767
+ }
768
+
769
+ /** Manifest config options not yet written into the capability's registration. Unregistered → nothing to add. */
770
+ function computeMissingConfigKeys(manifest: CapabilityManifest, configSource: string): MissingConfigKey[] {
771
+ if (manifest.configOptions.length === 0) return [];
772
+ const location = locateRegistration(configSource, manifest.name);
773
+ if (!location) return [];
774
+ return manifest.configOptions
775
+ .filter((option) => !location.presentKeys.includes(option.key))
776
+ .map((option) => ({
777
+ key: option.key,
778
+ // Conditional, because an absent default is a real state and not a missing field: an option the
779
+ // manifest states none for is required, and the plan says so by carrying none either.
780
+ ...(option.default === undefined ? {} : { default: option.default }),
781
+ ...(option.choices === undefined ? {} : { choices: option.choices }),
782
+ describe: option.describe,
783
+ // Decided against this Worker's own source, by the same function `pithy add` calls — so the two
784
+ // commands write the same line for the same option, which is the whole reason the renderers are
785
+ // shared (#171). An `upgrade` on a project that predates the scaffolded constant keeps the literal.
786
+ ...(option.constant && declaresConstant(configSource, option.constant) ? { constant: option.constant } : {}),
787
+ }));
788
+ }
789
+
790
+ /** Read a Worker's pithy.config.ts source as text (never executed here); an unreadable file yields no config drift. */
791
+ async function readConfigSource(workerDir: string): Promise<string> {
792
+ try {
793
+ return await readFile(join(workerDir, "pithy.config.ts"), "utf8");
794
+ } catch {
795
+ return "";
796
+ }
797
+ }
798
+
799
+ /**
800
+ * The Worker entry's source as text, or `""` when there is no entry to read.
801
+ *
802
+ * Degraded per contributor, like the config source beside it: a Worker whose `wrangler.jsonc` names no
803
+ * `main` — a front end that joins the dev set through `pithy.worker.jsonc` alone — has no entry, and a
804
+ * plan that threw over it would take the other four contributors down with it.
805
+ */
806
+ async function readEntrySource(workerDir: string): Promise<string> {
807
+ const path = await workerEntryPath(workerDir).catch(() => null);
808
+ if (path === null) return "";
809
+ // Discarded on purpose, on the terms `readConfigSource` above is — `readOptionalFile.test.ts` holds the
810
+ // sentence. An entry that will not open establishes no export drift; naming every class the Worker
811
+ // binds would be a guess, and taking the other four contributors down over it is #371's own defect.
812
+ return (await readOptionalFile(path).catch(() => null)) ?? "";
813
+ }
814
+
815
+ /**
816
+ * The Durable Object classes this capability binds here that the Worker's entry does not export.
817
+ *
818
+ * **A plan reports what an apply writes.** The apply wrote these and no plan mentioned them, so a project
819
+ * wired before that landed — binding present, export nowhere — read as clean under `pithy doctor` and
820
+ * `pithy upgrade --dry-run` while `wrangler deploy` refused it by name. That is #428's own shape one level
821
+ * up: a property of the entry that nobody states, so nothing checks it, and the failure arrives at deploy.
822
+ * `doctor` is the command an adopter runs *because* something is wrong, so it is the last place a silent
823
+ * property belongs.
824
+ *
825
+ * An entry that could not be read reports nothing rather than everything: an unreadable file is not an
826
+ * entry missing an export, and a plan that guessed would name every class the Worker binds.
827
+ */
828
+ function computeMissingEntryExports(bindings: readonly BindingSpec[], entrySource: string): string[] {
829
+ if (entrySource === "") return [];
830
+ return durableObjectExports(bindings)
831
+ .filter((entry) => !exportsName(entrySource, entry.className))
832
+ .map((entry) => entry.className);
833
+ }
834
+
835
+ /** A Worker with no `wrangler.jsonc` has no stanzas to reconcile — report no binding drift rather than failing. */
836
+ async function readStanzas(workerDir: string): Promise<{ env: string; stanza: WranglerStanza }[]> {
837
+ try {
838
+ return envStanzas((await readWranglerConfig(workerDir)) as WranglerStanza);
839
+ } catch {
840
+ return [];
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Build the read-only reconcile plan for **one Worker**. For every capability *that Worker composes* and has
846
+ * not ejected, report the required bindings missing from each of its environments and the manifest config
847
+ * options missing from its `apps/<name>/pithy.config.ts` call, plus the Worker's pending-migration count for
848
+ * `env`. Manifests come from the project root (`node_modules/@pithy-sh/*`); everything else is per Worker.
849
+ * Writes nothing — safe for `pithy doctor` to call on every run.
850
+ */
851
+ export async function buildReconcilePlan(options: BuildReconcilePlanOptions): Promise<ReconcilePlan> {
852
+ const { projectDir, workerDir, env } = options;
853
+ // The deployed name, kept under its own binding because two different consumers want two different
854
+ // things from it. `readLedger` below builds a migration scope, where the Worker is identified the way
855
+ // the migration ledger identifies it; the plan this returns is read by a person or a script holding the
856
+ // checkout, where the `apps/` directory is the useful handle. Collapsing them is pithy-sh/pithy#144.
857
+ const deployedAs = options.worker ?? basename(workerDir);
858
+ const capabilities = options.capabilities ?? allCapabilities(await loadWorkerConfig(workerDir));
859
+ const readLedger = options.readLedger ?? defaultReadLedger;
860
+
861
+ const { manifests } = await availableManifests(projectDir);
862
+ const ejected = await ejectedCapabilities(workerDir);
863
+ const configSource = await readConfigSource(workerDir);
864
+ const entrySource = await readEntrySource(workerDir);
865
+ const stanzas = await readStanzas(workerDir);
866
+ // The Worker's own composed set, by name. Manifests resolve from the shared root install, so this is the
867
+ // only thing that distinguishes "installed in the project" from "part of this Worker".
868
+ const composed = new Set(capabilities.map((capability) => capability.name));
869
+ // The composed instances, by name. Their `requiredBindings` are config-aware where a manifest cannot
870
+ // be — see `effectiveBindings`.
871
+ const byName = new Map(capabilities.map((capability) => [capability.name, capability]));
872
+
873
+ // Resolved before the loop below, because every one of that loop's three contributors resolves
874
+ // `optional` through `effectiveBindings` and must resolve it the same way. `readDeclinedBindings`
875
+ // reports rather than throws: a declaration that will not parse costs its own line and leaves the
876
+ // rest of the plan standing, exactly as the ledger and entitlement contributors do (#371).
877
+ const read = readDeclinedBindings(options.workerConfig ?? { capabilities });
878
+ const declinedBindings: BindingDeclines =
879
+ read.state === "invalid"
880
+ ? { state: "invalid", problem: read.problem }
881
+ : { state: "read", declines: resolveDeclines(read.declared, manifests, composed, ejected, stanzas, byName) };
882
+ // The names an upgrade is actually leaving out. A refused decline changes nothing about what is
883
+ // written — the refusal happens at the apply gate, so the plan still reports the binding as missing
884
+ // and the adopter sees both facts.
885
+ const honored = new Set(
886
+ declinedBindings.state === "read"
887
+ ? declinedBindings.declines.filter((decline) => decline.state === "honored").map((decline) => decline.name)
888
+ : [],
889
+ );
890
+
891
+ const perCapability: CapabilityReconcile[] = [];
892
+ for (const manifest of manifests) {
893
+ if (ejected.includes(manifest.name)) continue; // ejected — reported below, never reconciled
894
+ if (!composed.has(manifest.name)) continue; // installed at the root for another Worker — not this one's
895
+ perCapability.push({
896
+ name: manifest.name,
897
+ missingBindings: computeMissingBindings(manifest, stanzas, byName.get(manifest.name), honored),
898
+ missingConfigKeys: computeMissingConfigKeys(manifest, configSource),
899
+ // The same binding set the stanzas are compared against — a class is exported exactly when this
900
+ // Worker binds it.
901
+ missingEntryExports: computeMissingEntryExports(
902
+ effectiveBindings(manifest, byName.get(manifest.name), honored),
903
+ entrySource,
904
+ ),
905
+ });
906
+ }
907
+ perCapability.sort((a, b) => a.name.localeCompare(b.name));
908
+ const ejectedSkipped = [...ejected].sort((a, b) => a.localeCompare(b));
909
+
910
+ // **Every contributor to this plan is guarded, and none of them is load-bearing (#371).** A plan is a
911
+ // report, and a report is read by an adopter trying to find out why something is wrong — so one
912
+ // contributor failing must cost its own line and leave the other four standing. The two below were the
913
+ // unguarded ones; the config source, the wrangler stanzas and the version-metadata read beside them
914
+ // have degraded per contributor since they were written.
915
+ //
916
+ // Neither guard takes a binding. `readLedger` reaches a customer's D1 and `findEntitlementGap` walks
917
+ // their source tree, so both throw with paths, ids and queries in them — nothing derived from either
918
+ // reaches this plan, which `pithy upgrade --json` prints.
919
+ //
920
+ // `try`/`catch` rather than `.catch()`, because `readLedger` is an injectable seam: a `.catch()` guards
921
+ // a rejected promise and not a function that threw before returning one.
922
+ let ledger: ProjectLedger;
923
+ try {
924
+ ledger = await readLedger({
925
+ projectDir,
926
+ workerDir,
927
+ worker: deployedAs,
928
+ env,
929
+ capabilities,
930
+ account: options.account,
931
+ });
932
+ } catch {
933
+ ledger = { state: "unavailable" };
934
+ }
935
+ // Report-only, and scoped to this Worker's own source: the gates are on its routes, and the provider is
936
+ // in its composed set, so the question is per Worker exactly as the rest of the plan is.
937
+ let entitlements: EntitlementGap;
938
+ try {
939
+ entitlements = { state: "read", gates: await (options.findGap ?? findEntitlementGap)(workerDir, capabilities) };
940
+ } catch {
941
+ entitlements = { state: "unavailable" };
942
+ }
943
+ // Not a capability binding, so it does not travel through the manifest path above: no capability
944
+ // requires it, every Worker wants it, and the platform populates it. It is a property of the scaffold,
945
+ // and the reason it is reconciled at all is that it shipped missing from both templates once already.
946
+ //
947
+ // Only when the config is actually readable. A Worker with no `wrangler.jsonc` — a Vite frontend that
948
+ // joins the dev set through `pithy.worker.jsonc` and never deploys — is not missing a binding, it is
949
+ // missing a deploy target, and reporting drift an apply then declines to fix is worse than silence.
950
+ // The same holds for a config with a syntax error: the honest answer is that this cannot be assessed.
951
+ const wrangler = await readWranglerConfig(workerDir).catch(() => null);
952
+ const missingVersionMetadata = wrangler !== null && !hasVersionMetadata(wrangler);
953
+ return {
954
+ ...workerIdentity({ name: deployedAs, dir: workerDir }),
955
+ env,
956
+ perCapability,
957
+ ejectedSkipped,
958
+ declinedBindings,
959
+ ledger,
960
+ entitlements,
961
+ // Across every composed capability, ejected ones included: eject copies the source, it does not
962
+ // change what that source composes against, and `createBackend` asks the same question of both.
963
+ missingPrerequisites: missingPrerequisites(manifests, composed),
964
+ missingVersionMetadata,
965
+ };
966
+ }
967
+
968
+ /**
969
+ * Append every binding a capability needs in this Worker to one environment's stanza.
970
+ *
971
+ * The entries are `project/bindingEntries.ts`'s, the same writer `pithy add` uses. Reconcile used to
972
+ * carry its own copy, "kept in lockstep by intent" with `add`'s, and the two had already drifted: `add`
973
+ * stamped a spec's `remote` flag and wrote the Workers AI binding, this did neither, so a capability
974
+ * wired by `pithy upgrade` got a different config from the same manifest than one wired by `pithy add`.
975
+ *
976
+ * The KV name proposal the writer returns is dropped here on purpose: `upgrade` reports drift, and a KV
977
+ * namespace title is something `pithy add` tells the adopter to create at the moment they compose the
978
+ * capability, not something a later reconcile can act on.
979
+ */
980
+ function appendBindings(
981
+ stanza: WranglerStanza,
982
+ manifest: CapabilityManifest,
983
+ scope: BindingScope,
984
+ composed: Capability | undefined,
985
+ declined: ReadonlySet<string>,
986
+ ): { written: MissingBinding[]; skipped: SkippedBinding[] } {
987
+ const written: MissingBinding[] = [];
988
+ const skipped: SkippedBinding[] = [];
989
+ // Same source of truth as the plan — see `effectiveBindings`. Writing from the manifest here while the
990
+ // plan reported from the composed instance would make `upgrade` decline to write the binding it had
991
+ // just told the adopter was missing.
992
+ for (const binding of effectiveBindings(manifest, composed, declined)) {
993
+ const write = appendBinding(stanza, binding, scope);
994
+ // `present` and `unsupported` are neither: the first is idempotency (the plan already excluded it),
995
+ // the second a kind with no array to be missing from, which `computeMissingBindings` skips too.
996
+ if (write.outcome === "written") written.push({ env: scope.env, name: binding.name, type: binding.type });
997
+ if (write.outcome === "skipped") {
998
+ skipped.push({ env: scope.env, name: binding.name, type: binding.type, reason: write.reason });
999
+ }
1000
+ }
1001
+ return { written, skipped };
1002
+ }
1003
+
1004
+ /**
1005
+ * Render one option's `// describe` + `key: default` lines at a given indent — the same two lines
1006
+ * `pithy add` renders, through the same function, because "matching `pithy add`" was a comment and not
1007
+ * a mechanism. This called `JSON.stringify` while `add` called `renderConfigValue`, so one manifest
1008
+ * default came out as `{"code":"chips"}` here and `{ code: "chips" }` there, and only the second
1009
+ * survived the `biome check` a scaffolded project runs on itself (#171).
1010
+ */
1011
+ function renderKeyLines(capability: string, keys: MissingConfigKey[], indent: string): string {
1012
+ const lines: string[] = [];
1013
+ for (const key of keys) {
1014
+ const value = key.constant ? { constant: key.constant } : key.default;
1015
+ // A required option — one the manifest states no default for — is reported as drift and cannot be
1016
+ // repaired here: `pithy upgrade` has no adopter to ask and no value it is entitled to pick, and the
1017
+ // decision is exactly the kind #412 refused to guess at. So it refuses, naming the option and what it
1018
+ // takes, rather than writing the word `undefined` into a config the adopter has to find later.
1019
+ if (value === undefined) throw requiredOptionRefusal({ capability, missing: [key] });
1020
+ lines.push(renderConfigOptionComment(key.describe, indent));
1021
+ lines.push(renderConfigOptionLine(key.key, value, indent));
1022
+ }
1023
+ return lines.join("\n");
1024
+ }
1025
+
1026
+ /**
1027
+ * Convert a one-liner `name(),` registration to block form, inserting the missing keys.
1028
+ *
1029
+ * The call is rendered by core, not written here: it is the third place a capability's name reaches
1030
+ * generated source, and `pithy add`'s two were the ones #183 was reported about. No trailing comma —
1031
+ * the regex below matches `name()` and leaves the file's own separating comma alone.
1032
+ */
1033
+ function convertOneLiner(source: string, name: string, indent: string, keys: MissingConfigKey[]): string {
1034
+ const block = renderCapabilityRegistration({
1035
+ name,
1036
+ indent,
1037
+ optionLines: [renderKeyLines(name, keys, `${indent} `)],
1038
+ trailingComma: false,
1039
+ });
1040
+ // `indent` is the indent this very regex captured when the registration was located, so the block
1041
+ // carries its own opening indent and the match is replaced whole.
1042
+ const re = new RegExp(`^[ \\t]*${escapeRegExp(name)}[ \\t]*\\(\\s*\\)`, "m");
1043
+ // A replacement function keeps any `$` in the rendered defaults literal.
1044
+ return source.replace(re, () => block);
1045
+ }
1046
+
1047
+ /**
1048
+ * Insert missing keys into an existing block registration, before its closing brace. Never rewrites a key.
1049
+ * A separating comma is spliced onto the prior property when it lacks a trailing one — otherwise inserting
1050
+ * after `{ x: 1 }` (an adopter's hand-written inline block) would produce `{ x: 1 y: 2 }`, invalid TypeScript.
1051
+ */
1052
+ function insertIntoBlock(capability: string, source: string, closeIndex: number, keys: MissingConfigKey[]): string {
1053
+ // The last real character of the block body: `,` or `{` (empty block) means no separator is needed.
1054
+ let last = closeIndex - 1;
1055
+ while (last >= 0 && /\s/.test(source[last] as string)) last--;
1056
+ const needComma = source[last] !== "," && source[last] !== "{";
1057
+
1058
+ // Splice the comma directly after the last property (not before the closing brace) so no stray space is
1059
+ // introduced, then insert the new keys before the — possibly shifted — closing brace.
1060
+ const withComma = needComma ? `${source.slice(0, last + 1)},${source.slice(last + 1)}` : source;
1061
+ const close = closeIndex + (needComma ? 1 : 0);
1062
+
1063
+ const lineStart = withComma.lastIndexOf("\n", close - 1) + 1;
1064
+ const prefixOnLine = withComma.slice(lineStart, close);
1065
+ if (prefixOnLine.trim() === "") {
1066
+ // The close brace sits on its own line — insert whole key lines above it.
1067
+ const inner = `${prefixOnLine} `;
1068
+ return `${withComma.slice(0, lineStart)}${renderKeyLines(capability, keys, inner)}\n${withComma.slice(lineStart)}`;
1069
+ }
1070
+ // Inline block (`name({ x: 1 })`) — append `key: value,` before the closing brace.
1071
+ // One space stands in for the indent: an inline block has no line of its own to sit on. The value is
1072
+ // rendered by the same function as every other writer's, so a hand-written block gets Biome's shape
1073
+ // too — the whole point of there being one renderer (#171).
1074
+ const inline = keys
1075
+ .map((key) => {
1076
+ const value = key.constant ? { constant: key.constant } : key.default;
1077
+ if (value === undefined) throw requiredOptionRefusal({ capability, missing: [key] });
1078
+ return renderConfigOptionLine(key.key, value, " ");
1079
+ })
1080
+ .join("");
1081
+ return `${withComma.slice(0, close)}${inline}${withComma.slice(close)}`;
1082
+ }
1083
+
1084
+ /** Options for {@link applyReconcilePlan} — the write step, run only by `pithy upgrade`. */
1085
+ export interface ApplyReconcilePlanOptions {
1086
+ /** The project root — where the capability manifests resolve from. */
1087
+ projectDir: string;
1088
+ /** The Worker's directory (`apps/<name>/`) — the pithy.config.ts and wrangler.jsonc this writes. */
1089
+ workerDir: string;
1090
+ /** The plan to apply, from {@link buildReconcilePlan}. */
1091
+ plan: ReconcilePlan;
1092
+ /** Run the Worker's pending migrations after reconciling; leaves them pending when false. */
1093
+ migrate: boolean;
1094
+ /** The environment migrations run against when `migrate` is set. */
1095
+ env: string;
1096
+ /**
1097
+ * The project name, resolved by the caller from the root `pithy.config.ts` (`requireProjectName`) and
1098
+ * passed as a plain string — the first segment of every `database_name` this proposes, and the owner
1099
+ * stamped on every database a `migrate` run touches.
1100
+ *
1101
+ * Optional, because the two uses have different stakes. A missing name costs the *proposal* nothing
1102
+ * worse than a binding-only entry, exactly as before, and `pithy doctor` says why. A missing name on a
1103
+ * **write** is not survivable the same way — an unstamped database is one any project can later claim
1104
+ * — so `migrate` without a project is refused below, before a single file is touched.
1105
+ */
1106
+ project?: string;
1107
+ /** The Worker's composed capabilities (libraries + app) — passed to the migration run. */
1108
+ capabilities: Capability[];
1109
+ /**
1110
+ * The Cloudflare account this project belongs to, or `null` when it names none. Required (#234) — this
1111
+ * is the write side, and `migrate` on a non-`dev` env runs the project's migrations against a live
1112
+ * schema in whichever account the credentials belong to.
1113
+ */
1114
+ account: CloudflareAccountSelection | null;
1115
+ /** Test seam: run migrations without a real Miniflare/D1 run. */
1116
+ runMigrate?: RunMigrate;
1117
+ }
1118
+
1119
+ /** What one capability's apply changed: the bindings and config keys actually added. */
1120
+ export interface CapabilityApplied {
1121
+ /** The capability's short name. */
1122
+ name: string;
1123
+ /**
1124
+ * The bindings added to wrangler.jsonc for this capability — **read back off the writer**, one entry
1125
+ * per environment actually written.
1126
+ *
1127
+ * This used to be `cap.missingBindings`: the plan, copied across verbatim the moment the apply loop
1128
+ * touched the capability at all. So a binding the writer declined was counted as added, by a command
1129
+ * whose whole job is to be believed about what it just changed (#318).
1130
+ */
1131
+ addedBindings: MissingBinding[];
1132
+ /** The bindings this capability needed that the writer could not compose, each with its reason. */
1133
+ skippedBindings: SkippedBinding[];
1134
+ /** The config option keys inserted into this capability's pithy.config.ts registration. */
1135
+ addedConfigKeys: string[];
1136
+ }
1137
+
1138
+ /** The summary {@link applyReconcilePlan} returns — what was written, what was skipped, whether migrations ran. */
1139
+ export interface ReconcileApplied {
1140
+ /** The Worker this apply targeted, carried through from the plan so a fan-out report can label it. */
1141
+ worker: string;
1142
+ /**
1143
+ * The same Worker's deployed script name, carried through from the plan beside `worker`.
1144
+ *
1145
+ * **Both names, or the payload changes shape with a flag.** `pithy upgrade --json` reports
1146
+ * `applied ?? plan` out of one `workers` array, so a key the plan carried and the apply dropped meant a
1147
+ * consumer that read `deployedAs` worked under `--dry-run` and got `undefined` on the run that actually
1148
+ * wrote something — the mode where being sure which script was reconciled matters most. #231.
1149
+ */
1150
+ deployedAs: string;
1151
+ /** Per capability that changed: the bindings and config keys added. */
1152
+ perCapability: CapabilityApplied[];
1153
+ /** Ejected capabilities, by name — reported, never touched. */
1154
+ ejectedSkipped: string[];
1155
+ /** Whether the migration step ran (only when the caller passed `migrate`). */
1156
+ migrated: boolean;
1157
+ /** The per-database migration runs, when `migrated`; empty otherwise. */
1158
+ migrations: DatabaseRun[];
1159
+ /** Whether this run added the `version_metadata` binding the Worker was missing. */
1160
+ addedVersionMetadata: boolean;
1161
+ /**
1162
+ * The Durable Object classes this run wrote an `export { … } from "…"` for into the Worker's entry.
1163
+ *
1164
+ * Reported for the reason every other field here is: `upgrade` writes a file in the adopter's repo, and
1165
+ * a change it makes without saying so is a `git diff` they have to reverse-engineer (#318). Empty on the
1166
+ * common run, which is what idempotent looks like.
1167
+ */
1168
+ addedEntryExports: string[];
1169
+ }
1170
+
1171
+ /** Add every plan capability's missing bindings to the Worker's wrangler.jsonc, comment-preserving. Returns what was added per capability. */
1172
+ async function applyBindings(
1173
+ projectDir: string,
1174
+ workerDir: string,
1175
+ plan: ReconcilePlan,
1176
+ project: string | undefined,
1177
+ capabilities: readonly Capability[],
1178
+ ): Promise<Map<string, { written: MissingBinding[]; skipped: SkippedBinding[] }>> {
1179
+ const added = new Map<string, { written: MissingBinding[]; skipped: SkippedBinding[] }>();
1180
+ // The one list every writer below resolves `optional` through, taken from the plan rather than
1181
+ // recomputed — `applyReconcilePlan` re-reads no file, so the plan is the only place the decline
1182
+ // exists on this side.
1183
+ const honored = honoredDeclines(plan);
1184
+ const caps = plan.perCapability.filter((cap) => cap.missingBindings.length > 0);
1185
+ if (caps.length === 0) return added;
1186
+
1187
+ const byName = new Map((await availableManifests(projectDir)).manifests.map((manifest) => [manifest.name, manifest]));
1188
+ const composedByName = new Map(capabilities.map((capability) => [capability.name, capability]));
1189
+ const config = (await readWranglerConfig(workerDir)) as WranglerStanza;
1190
+ const stanzas = envStanzas(config);
1191
+ let touched = false;
1192
+ for (const cap of caps) {
1193
+ const manifest = byName.get(cap.name);
1194
+ if (!manifest) continue; // installed manifest vanished — nothing to wire
1195
+ // Each stanza is written for the environment it *is*, so the name it proposes has that environment
1196
+ // in it. `envStanzas` already pairs them on the read side; the write side uses the same pairing.
1197
+ const written: MissingBinding[] = [];
1198
+ const skipped: SkippedBinding[] = [];
1199
+ for (const { env, stanza } of stanzas) {
1200
+ const result = appendBindings(
1201
+ stanza,
1202
+ manifest,
1203
+ { ...(project === undefined ? {} : { project }), env, capability: manifest.name },
1204
+ composedByName.get(cap.name),
1205
+ honored,
1206
+ );
1207
+ written.push(...result.written);
1208
+ skipped.push(...result.skipped);
1209
+ }
1210
+ // The same list the stanzas got, never the manifest's whole set: a class migration tag registers a
1211
+ // class against the script, and one for a binding this Worker does not derive registers an actor
1212
+ // nothing can reach. A tag is applied once and never revisited, so it is not a mistake a later run
1213
+ // repairs — `capabilities/add.ts`'s `wiredBindings` states the rule for the other writer.
1214
+ appendDurableObjectMigrations(config, effectiveBindings(manifest, composedByName.get(cap.name), honored));
1215
+ added.set(cap.name, { written, skipped });
1216
+ // Only a real write dirties the file. A capability whose every binding was declined leaves
1217
+ // `wrangler.jsonc` byte-identical, and rewriting it would be a diff saying nothing happened.
1218
+ if (written.length > 0) touched = true;
1219
+ }
1220
+ if (touched) await writeWranglerConfig(workerDir, config);
1221
+ return added;
1222
+ }
1223
+
1224
+ /**
1225
+ * Write every composed capability's Durable Object exports into the Worker's entry — **the other writer**
1226
+ * of the two halves of a Durable Object, and the one #428's first fix missed.
1227
+ *
1228
+ * `pithy add` was not the only command putting a `durable_objects.bindings` entry with a `class_name` in
1229
+ * it into a Worker: the reconcile above writes one into every environment that is missing it, and left the
1230
+ * export to a human. So the defect survived on the command an adopter reaches for *because* something is
1231
+ * wrong, and `pithy upgrade` would report a Worker fully reconciled that `wrangler deploy` still refuses:
1232
+ *
1233
+ * Your Worker depends on the following Durable Objects, which are not exported in your entrypoint
1234
+ * file: MultiplayerSession.
1235
+ *
1236
+ * **Over every composed capability, not only the ones with a missing binding.** A project scaffolded
1237
+ * before this landed has the bindings already and the export nowhere, so nothing in `wrangler.jsonc` is
1238
+ * missing — and that project is exactly who runs `pithy upgrade`. {@link computeMissingEntryExports} is
1239
+ * what puts the same set in the plan, so `doctor` and `--dry-run` name it too; the modules it needs to
1240
+ * *write* the line come from the manifests here, which is why this derives the set again rather than
1241
+ * reading the plan's class names. {@link withDurableObjectExports} is idempotent, so a Worker that is
1242
+ * already right is read and left alone.
1243
+ *
1244
+ * A Worker whose config names no `main` is passed over rather than refused, unlike in `pithy add`. Add
1245
+ * wires one capability into one Worker the adopter just named; upgrade fans out over every Worker in the
1246
+ * project, and a Worker with a Durable Object and no entry cannot deploy for a reason older and plainer
1247
+ * than this one. Refusing here would abandon the fan-out mid-write over a config that was already broken.
1248
+ */
1249
+ async function applyEntryExports(
1250
+ projectDir: string,
1251
+ workerDir: string,
1252
+ plan: ReconcilePlan,
1253
+ capabilities: readonly Capability[],
1254
+ ): Promise<string[]> {
1255
+ const byName = new Map((await availableManifests(projectDir)).manifests.map((manifest) => [manifest.name, manifest]));
1256
+ const composedByName = new Map(capabilities.map((capability) => [capability.name, capability]));
1257
+ // Same list the stanza writer used. A declined Durable Object would otherwise stay exported from the
1258
+ // entry, pulling the class into the bundle for a binding nothing declares — #428's shape.
1259
+ const honored = honoredDeclines(plan);
1260
+ const exports = plan.perCapability.flatMap((cap) => {
1261
+ const manifest = byName.get(cap.name);
1262
+ // Same source of truth as the binding writer — see `effectiveBindings`. Exporting a class this Worker
1263
+ // does not bind would pull a Durable Object into the bundle for nothing.
1264
+ return manifest ? durableObjectExports(effectiveBindings(manifest, composedByName.get(cap.name), honored)) : [];
1265
+ });
1266
+ if (exports.length === 0) return [];
1267
+
1268
+ const path = await workerEntryPath(workerDir).catch(() => null);
1269
+ const source = path === null ? null : await readOptionalFile(path);
1270
+ if (path === null || source === null) return [];
1271
+
1272
+ const written = withDurableObjectExports(source, exports);
1273
+ if (written === source) return [];
1274
+ await writeFile(path, written);
1275
+ // What went in, decided by the same reader the writer used rather than by the intention — #318's rule,
1276
+ // and the reason this is reported at all: `upgrade` editing an adopter's entry and saying nothing is
1277
+ // that issue's shape. A class the entry already carried is not something this run added.
1278
+ return [...new Set(exports.filter((entry) => !exportsName(source, entry.className)).map((e) => e.className))];
1279
+ }
1280
+
1281
+ /** Insert every plan capability's missing config keys into the Worker's pithy.config.ts, never rewriting an existing key. */
1282
+ async function applyConfigKeys(workerDir: string, plan: ReconcilePlan): Promise<Map<string, string[]>> {
1283
+ const added = new Map<string, string[]>();
1284
+ const caps = plan.perCapability.filter((cap) => cap.missingConfigKeys.length > 0);
1285
+ if (caps.length === 0) return added;
1286
+
1287
+ const path = join(workerDir, "pithy.config.ts");
1288
+ let source = await readFile(path, "utf8");
1289
+ let changed = false;
1290
+ for (const cap of caps) {
1291
+ // Re-locate on the current (possibly-mutated) source so offsets stay valid and already-present keys
1292
+ // are re-checked — that makes a re-apply of a stale plan a no-op rather than a duplicate.
1293
+ const location = locateRegistration(source, cap.name);
1294
+ if (!location) continue;
1295
+ const toAdd = cap.missingConfigKeys.filter((key) => !location.presentKeys.includes(key.key));
1296
+ if (toAdd.length === 0) continue;
1297
+ source =
1298
+ location.form === "oneliner"
1299
+ ? convertOneLiner(source, cap.name, location.indent, toAdd)
1300
+ : insertIntoBlock(cap.name, source, location.closeIndex, toAdd);
1301
+ added.set(
1302
+ cap.name,
1303
+ toAdd.map((key) => key.key),
1304
+ );
1305
+ changed = true;
1306
+ }
1307
+ if (changed) await writeFile(path, source);
1308
+ return added;
1309
+ }
1310
+
1311
+ /**
1312
+ * The project a `--migrate` run claims each database for, or a refusal. `pithy upgrade` reconciles wiring
1313
+ * happily without a project name — the proposals just carry their binding — but the moment it is asked to
1314
+ * migrate, the name stops being cosmetic: it is the owner stamped on every database the run touches, and a
1315
+ * database left unstamped is one any other project can later claim.
1316
+ */
1317
+ function requireMigrationProject(project: string | undefined): string {
1318
+ if (project === undefined) {
1319
+ throw new ValidationError({
1320
+ message: "pithy upgrade --migrate needs a project name.",
1321
+ action:
1322
+ "Set `name` in pithy.config.ts, then run pithy upgrade --migrate again. It stamps each database as this project's, so another project's database is refused instead of silently merged.",
1323
+ });
1324
+ }
1325
+ return project;
1326
+ }
1327
+
1328
+ /**
1329
+ * The required options in a plan that nothing here can supply a value for — a manifest option with no
1330
+ * `default` and no `constant`.
1331
+ *
1332
+ * `pithy upgrade` has no adopter to ask and no value it is entitled to pick: which billing subject a
1333
+ * project uses is exactly the decision #412 refused to guess at. So the plan is refused whole, naming every
1334
+ * such option across every capability, and the Worker is left as it was.
1335
+ */
1336
+ function refuseUnwritableConfigKeys(plan: ReconcilePlan): void {
1337
+ for (const cap of plan.perCapability) {
1338
+ const missing = cap.missingConfigKeys.filter((key) => key.default === undefined && key.constant === undefined);
1339
+ if (missing.length > 0) throw requiredOptionRefusal({ capability: cap.name, missing });
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Every decline this Worker's composition cannot honor, refused before the first write.
1345
+ *
1346
+ * Three states get here and all three mean the adopter believes a binding is being left out that is
1347
+ * not — a belief that must be corrected before an upgrade writes, not after. `invalid` is the
1348
+ * declaration itself; `required` is a binding some capability needs outright, where leaving it out is a
1349
+ * boot failure rather than a configuration; `undeclinable` is a kind where `optional` means "not
1350
+ * provisioned yet" and declining only hides the command that fixes it.
1351
+ *
1352
+ * **`unrecognized` is deliberately not here.** `pithy remove <capability>` leaves exactly that state
1353
+ * behind, and a CLI that refused on it would create a failure no command could clear. It is reported by
1354
+ * `doctor` and by the upgrade summary, and it changes nothing about what gets written.
1355
+ *
1356
+ * Named together for the same reason `refuseUnwritableConfigKeys` names its options together: an
1357
+ * adopter fixing this edits one file once.
1358
+ */
1359
+ function refuseUnhonorableDeclines(plan: ReconcilePlan): void {
1360
+ const refusal = declineRefusal(plan);
1361
+ if (refusal) throw refusal;
1362
+ }
1363
+
1364
+ /**
1365
+ * The refusal a plan's declines earn, or `null` if it has none.
1366
+ *
1367
+ * **Separated from the throw so a caller can ask before it calls.** `applyReconcilePlan` catches
1368
+ * nothing and reports one shape for every failure — "Upgrade failed partway. Its wiring may hold part
1369
+ * of the plan" — which is true of a write that died mid-file and false of this, which happens before
1370
+ * the first byte. An adopter told their wiring is half-written when it is untouched will go looking for
1371
+ * damage that is not there, and never sees the action line naming the entry to remove.
1372
+ *
1373
+ * So `pithy upgrade` asks first and reports the refusal with its own words; `applyReconcilePlan` still
1374
+ * throws, because every other caller wants the failure rather than a value to inspect.
1375
+ *
1376
+ * The config-key refusal beside it is deliberately not folded in here: it has the same shape and the
1377
+ * same defect, and moving it changes output this issue did not touch. It wants its own change.
1378
+ */
1379
+ export function declineRefusal(plan: ReconcilePlan): PithyError | null {
1380
+ if (plan.declinedBindings.state === "invalid") {
1381
+ return new ValidationError({
1382
+ message: "This Worker's `declinedBindings` declaration is not valid.",
1383
+ action: `Fix \`declinedBindings\` in ${plan.worker}'s pithy.config.ts. Each entry is a binding name mapped to a one-line reason. ${plan.declinedBindings.problem}`,
1384
+ });
1385
+ }
1386
+ const refused = plan.declinedBindings.declines.filter(
1387
+ (decline) => decline.state === "required" || decline.state === "undeclinable",
1388
+ );
1389
+ if (refused.length === 0) return null;
1390
+ const lines = refused.map((decline) =>
1391
+ decline.state === "required"
1392
+ ? `${decline.name} (${decline.type}) — ${decline.capability} requires it, so it is never left out.`
1393
+ : `${decline.name} (${decline.type}) — this kind cannot be declined. ${undeclinableReason(decline.type)}${
1394
+ decline.type === "durable_object" ? "" : ` Run \`pithy ${decline.capability} provision\`.`
1395
+ }`,
1396
+ );
1397
+ return new ValidationError({
1398
+ message: `This Worker declines ${refused.length === 1 ? "a binding it cannot decline" : "bindings it cannot decline"}.`,
1399
+ action: `Remove ${refused.length === 1 ? "the entry" : "these entries"} from \`declinedBindings\` in ${plan.worker}'s pithy.config.ts. ${lines.join(" ")}`,
1400
+ });
1401
+ }
1402
+
1403
+ /**
1404
+ * Apply a reconcile plan — the write step behind `pithy upgrade`, never called by `doctor`. Adds the
1405
+ * missing bindings to the Worker's `wrangler.jsonc` and the missing config keys to its `pithy.config.ts`
1406
+ * (a one-liner call becomes block form; an existing block gains only the absent keys — an adopter-changed
1407
+ * value is never touched), then runs that Worker's migrations when `migrate` is set. Everything written is
1408
+ * inside `workerDir`. Idempotent: re-running after a build finds nothing missing and writes nothing.
1409
+ */
1410
+ export async function applyReconcilePlan(options: ApplyReconcilePlanOptions): Promise<ReconcileApplied> {
1411
+ const { projectDir, workerDir, plan, env, capabilities } = options;
1412
+ const runMigrate = options.runMigrate ?? defaultRunMigrate;
1413
+
1414
+ // The project to migrate as, or null for "don't migrate" — resolved first, so a nameless project fails
1415
+ // having written nothing rather than mid-fan-out with one Worker already reconciled. Reconciling wiring
1416
+ // is survivable without a name; writing to a database is not.
1417
+ const migrateAs = options.migrate ? requireMigrationProject(options.project) : null;
1418
+
1419
+ // Every required option this plan cannot write, refused **before the first write**, for the same reason
1420
+ // `migrateAs` is resolved above it: `applyBindings` rewrites `wrangler.jsonc`, and a refusal raised after
1421
+ // it leaves the Worker half-reconciled — new bindings on disk, no config keys, and every *other*
1422
+ // capability's keys in the same run silently dropped. Worse, the refusal is unfixable from here by
1423
+ // construction, so `pithy upgrade` would report drift it had just made harder to see.
1424
+ //
1425
+ // Named together rather than one at a time: an adopter fixing config edits one file once, and a refusal
1426
+ // that surfaces the second required option only after they have fixed the first is two round trips for
1427
+ // one edit.
1428
+ // Before `refuseUnwritableConfigKeys`, and in that slot for exactly that comment's reason: a decline
1429
+ // this composition cannot honor is unfixable from here, and raising it after `applyBindings` has
1430
+ // rewritten `wrangler.jsonc` would leave the Worker half-reconciled against a belief that was wrong.
1431
+ refuseUnhonorableDeclines(plan);
1432
+ refuseUnwritableConfigKeys(plan);
1433
+
1434
+ const addedBindings = await applyBindings(projectDir, workerDir, plan, options.project, capabilities);
1435
+ // After the bindings, deliberately: the export is the second half of a binding, and an entry re-exporting
1436
+ // a class nothing binds is the wrong file to leave behind if the write above throws.
1437
+ const addedEntryExports = await applyEntryExports(projectDir, workerDir, plan, capabilities);
1438
+ const addedConfigKeys = await applyConfigKeys(workerDir, plan);
1439
+ // Idempotent, and a no-op on a Worker that already declares it — including one that names a different
1440
+ // binding, which is reported rather than repointed.
1441
+ const addedVersionMetadata = plan.missingVersionMetadata ? await applyVersionMetadata(workerDir) : false;
1442
+
1443
+ const perCapability: CapabilityApplied[] = [];
1444
+ for (const cap of plan.perCapability) {
1445
+ const bindings = addedBindings.get(cap.name) ?? { written: [], skipped: [] };
1446
+ const keys = addedConfigKeys.get(cap.name) ?? [];
1447
+ // A capability that wrote nothing but skipped something still belongs in the report — that is the
1448
+ // whole point of naming a skip. Silence here is the shape #318 was reported about.
1449
+ if (bindings.written.length === 0 && bindings.skipped.length === 0 && keys.length === 0) continue;
1450
+ perCapability.push({
1451
+ name: cap.name,
1452
+ addedBindings: bindings.written,
1453
+ skippedBindings: bindings.skipped,
1454
+ addedConfigKeys: keys,
1455
+ });
1456
+ }
1457
+
1458
+ let migrated = false;
1459
+ let migrations: DatabaseRun[] = [];
1460
+ if (migrateAs !== null) {
1461
+ migrations = await runMigrate({
1462
+ projectDir,
1463
+ workerDir,
1464
+ worker: plan.worker,
1465
+ env,
1466
+ capabilities,
1467
+ account: options.account,
1468
+ project: migrateAs,
1469
+ });
1470
+ migrated = true;
1471
+ }
1472
+
1473
+ return {
1474
+ worker: plan.worker,
1475
+ deployedAs: plan.deployedAs,
1476
+ perCapability,
1477
+ ejectedSkipped: plan.ejectedSkipped,
1478
+ migrated,
1479
+ migrations,
1480
+ addedVersionMetadata,
1481
+ addedEntryExports,
1482
+ };
1483
+ }