@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,425 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingSpec } from "@pithy-sh/core/src/capability/bindings";
5
+ import { isValidEnvironment } from "@pithy-sh/core/src/naming/environment";
6
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
7
+ import { workflowScriptName } from "@pithy-sh/core/src/workflow/naming";
8
+
9
+ /**
10
+ * One writer for the `wrangler.jsonc` entry a capability's binding needs, and one reader for whether a
11
+ * stanza already has it.
12
+ *
13
+ * `capabilities/add.ts` and `capabilities/reconcile.ts` each carried their own copy of both, "kept in
14
+ * lockstep by intent" — and intent is not a mechanism. They already disagreed: `add` stamped a spec's
15
+ * `remote` flag and wrote the Workers AI binding, `reconcile` did neither, so the same manifest produced
16
+ * two different configs depending on whether the capability arrived through `pithy add` or through
17
+ * `pithy upgrade`. This module is the mechanism, so the drift has nowhere to live.
18
+ *
19
+ * The rule for what belongs here is {@link isWrittenBinding}: a kind is written when every field
20
+ * wrangler's validator requires is derivable offline. Everything else — a Vectorize `index_name`, a
21
+ * Secrets Store entry — is a provisioner's to write, and a partial entry would be worse than none
22
+ * because wrangler refuses to load the file at all.
23
+ */
24
+
25
+ /** A binding entry as wrangler files it: the name, plus whatever the spec can state offline. */
26
+ interface BindingEntry {
27
+ binding: string;
28
+ database_name?: string;
29
+ remote?: boolean;
30
+ }
31
+
32
+ /** A single Durable Object namespace binding, as wrangler writes it. */
33
+ interface DurableObjectBinding {
34
+ name: string;
35
+ class_name: string;
36
+ remote?: boolean;
37
+ }
38
+
39
+ /**
40
+ * One `ratelimits` entry. `name` is the binding — wrangler spells this one differently from every other
41
+ * array, which is its own reason for there being a single writer.
42
+ */
43
+ interface RateLimitBinding {
44
+ name: string;
45
+ namespace_id: string;
46
+ simple: { limit: number; period: number };
47
+ }
48
+
49
+ /** One `workflows` entry on the **app** Worker: a cross-script binding into the capability's host. */
50
+ interface WorkflowBindingEntry {
51
+ binding: string;
52
+ name: string;
53
+ class_name: string;
54
+ script_name: string;
55
+ }
56
+
57
+ /** A Durable Object class migration — a versioned tag registering (or dropping) DO classes. */
58
+ interface DurableObjectMigration {
59
+ tag: string;
60
+ new_sqlite_classes?: string[];
61
+ }
62
+
63
+ /**
64
+ * The binding arrays of one wrangler stanza — every key this module touches. `durable_objects.bindings`
65
+ * is per-environment (each environment gets its own DO namespace); DO class `migrations` are **top-level
66
+ * only** (they register the class against the script, not per-environment), so they live on the root
67
+ * config — see {@link appendDurableObjectMigrations}.
68
+ *
69
+ * `ai` is a single object, not an array: a Worker has exactly one Workers AI binding.
70
+ */
71
+ export interface WranglerStanza {
72
+ d1_databases?: BindingEntry[];
73
+ kv_namespaces?: BindingEntry[];
74
+ r2_buckets?: BindingEntry[];
75
+ ai?: BindingEntry;
76
+ ratelimits?: RateLimitBinding[];
77
+ workflows?: WorkflowBindingEntry[];
78
+ durable_objects?: { bindings: DurableObjectBinding[] };
79
+ migrations?: DurableObjectMigration[];
80
+ env?: Record<string, WranglerStanza | undefined>;
81
+ }
82
+
83
+ /**
84
+ * What one call to {@link appendBinding} did.
85
+ *
86
+ * **The writer answers "did I write it", because nothing else can.** `pithy upgrade` used to report the
87
+ * bindings its *plan* named and never asked the writer whether any of them landed, so five capabilities'
88
+ * Workflow bindings were counted as added, silently declined, and correctly reported missing by `pithy
89
+ * doctor` seconds later (#318). A count derived from an intention is a claim about the future; this is
90
+ * the return value that makes it a claim about the file.
91
+ *
92
+ * `skipped` carries a `reason` written for an operator reading a terminal, not for a log: it names what
93
+ * the entry needed and did not have, so the next step is obvious.
94
+ */
95
+ export type BindingWrite =
96
+ /** An entry was appended to the stanza. `proposed` is set only for KV, whose title wrangler cannot hold. */
97
+ | { outcome: "written"; proposed?: ProposedName }
98
+ /** The stanza already declares this binding. Left exactly as the adopter has it. */
99
+ | { outcome: "present" }
100
+ /** A kind this module writes no array for — a `secret`, a `vectorize` index, a `queue`, an `email` send binding, a `service`. */
101
+ | { outcome: "unsupported" }
102
+ /** The entry could not be composed honestly. Nothing was written, and this says what was missing. */
103
+ | { outcome: "skipped"; reason: string };
104
+
105
+ /** A `<project>-<env>-<binding>` name proposed for a resource the adopter creates themselves. */
106
+ export interface ProposedName {
107
+ /** The Worker env binding the name is proposed for (e.g. `SESSIONS`). */
108
+ binding: string;
109
+ /** The environment whose stanza declares it — `dev` for the top-level one. */
110
+ env: string;
111
+ /** The proposed resource name, composed by the one naming rule: `<project>-<env>-<binding>`. */
112
+ name: string;
113
+ }
114
+
115
+ /** Which capability, project, and environment a stanza's entries are composed for. */
116
+ export interface BindingScope {
117
+ /** The project name, or absent when none was resolved — in which case nothing is proposed or derived. */
118
+ project?: string;
119
+ /** The environment this stanza *is* — `dev` for the top-level one, else the `env.<name>` key. */
120
+ env: string;
121
+ /** The capability the bindings belong to — the `<capability>` segment of a Workflow's deployed names. */
122
+ capability: string;
123
+ }
124
+
125
+ /**
126
+ * The prefix every DO class-migration tag Pithy allocates carries: `v1`, `v2`, … in add order.
127
+ *
128
+ * A tag names one step, and Cloudflare remembers the last one it applied — which is why the number
129
+ * matters, and why {@link appendDurableObjectMigrations} never reuses one.
130
+ */
131
+ const DO_MIGRATION_TAG_PREFIX = "v";
132
+
133
+ /**
134
+ * The rate-limit policy a `ratelimit` binding is written with: **100 requests per 60 seconds, per client
135
+ * IP**.
136
+ *
137
+ * A tier-1 edge limiter is a flood guard, not a product rule — `@pithy-sh/auth`'s middleware says so
138
+ * itself ("the limit and window are set on the binding in `wrangler.jsonc`, not here"), and the per-action
139
+ * caps that *are* a product rule live in Better Auth's D1 limiter one tier down. So the number belongs in
140
+ * the adopter's config where they can tune it, and the only question here is what it starts at.
141
+ *
142
+ * 100/60 is high enough that no human hits it and low enough to blunt credential stuffing. `period` is not
143
+ * free-form: Cloudflare accepts **10 or 60 only**, so a "sensible" 300 would be a config wrangler refuses.
144
+ */
145
+ const RATE_LIMIT_REQUESTS = 100;
146
+ const RATE_LIMIT_PERIOD_SECONDS = 60;
147
+
148
+ /**
149
+ * The `namespace_id` for a rate limiter, derived from its binding name.
150
+ *
151
+ * Cloudflare has the adopter choose this, and it is the counter's identity: two bindings sharing one id
152
+ * share one budget. Deriving it from the binding name rather than counting entries is what makes the
153
+ * value **stable** — the same across environments, across a re-add, across `pithy upgrade` retrofitting an
154
+ * older project, and independent of the order capabilities were composed in. A positional counter would
155
+ * renumber every limiter the moment one was removed, silently merging two budgets.
156
+ *
157
+ * Four digits, from an FNV-1a hash: numeric because every Cloudflare example is, and bounded because the
158
+ * id is a label rather than an address. Two different bindings colliding would share one budget — a
159
+ * limiter that trips sooner than its own traffic explains — which is why it is worth saying that no
160
+ * shipped capability declares a second rate limiter, and that this is the line to revisit when one does.
161
+ */
162
+ export function rateLimitNamespaceId(binding: string): string {
163
+ let hash = 0x811c9dc5;
164
+ for (let i = 0; i < binding.length; i++) {
165
+ hash ^= binding.charCodeAt(i);
166
+ hash = Math.imul(hash, 0x01000193) >>> 0;
167
+ }
168
+ return String(1000 + (hash % 9000));
169
+ }
170
+
171
+ /**
172
+ * Stamp the spec's `remote` flag onto an emitted entry. An unset `remote` writes no key at all: the
173
+ * capability has no opinion, and an explicit `remote: false` would pin the binding to local emulation the
174
+ * adopter may well want to override.
175
+ */
176
+ function withRemote<Entry extends object>(entry: Entry, binding: BindingSpec): Entry {
177
+ return binding.remote === undefined ? entry : { ...entry, remote: binding.remote };
178
+ }
179
+
180
+ /**
181
+ * The `<project>-<env>-<binding>` name to propose for a binding, or `undefined` when there is nothing safe
182
+ * to propose.
183
+ *
184
+ * **Through the facade, per namespace.** A D1 database and a KV namespace are different Cloudflare
185
+ * namespaces with different limits — no published cap and 512 respectively — and calling the generic
186
+ * composer held both to 63, which is R2's number and only R2's.
187
+ *
188
+ * Two cases propose nothing rather than guessing. No project name: a guessed prefix is worse than none,
189
+ * since every command that later recomputes the name would compute a different one. And an `env.<key>` the
190
+ * naming scheme does not accept — an eleven-character environment eats the room every project name was
191
+ * already accepted against, so no name for it is honest. The binding is still wired either way.
192
+ */
193
+ function proposeName(scope: BindingScope, binding: string, kind: "d1" | "kv"): string | undefined {
194
+ if (scope.project === undefined || !isValidEnvironment(scope.env)) return undefined;
195
+ return resourceNames(scope.project).env(scope.env)[kind](binding);
196
+ }
197
+
198
+ /**
199
+ * The complete `workflows` entry for a binding, or `undefined` when it cannot be named honestly.
200
+ *
201
+ * Every field wrangler requires is derived rather than guessed: `name` is the deployed Workflow
202
+ * (`<project>-<env>-<capability>-<job>`, through core's own composer, so it is byte-identical to what the
203
+ * capability's provisioner deploys under), `class_name` is the exported `WorkflowEntrypoint` the manifest
204
+ * states, and `script_name` is the capability's host Worker — a cross-script binding, because the class
205
+ * lives in the host and never in the app.
206
+ *
207
+ * `script_name` goes through the naming facade's `worker` kind, so it is held to a Worker's 63 rather than
208
+ * a Workflow's 64. The one-character gap is real, and it is the one that survives an adopter enabling
209
+ * workers.dev. Same call `project/appBindings.ts` makes at provision time, so the two writers cannot
210
+ * disagree about the same Worker.
211
+ *
212
+ * **This writer has one shape, and it is the cross-Worker one.** A capability's Workflow class is
213
+ * exported by that capability's host Worker, never by the app, so there is no same-Worker branch here to
214
+ * be missing — #318 guessed there was. The same-Worker shape (`class_name`, no `script_name`) is a
215
+ * different writer for a different owner: `project/appWorkflows.ts`, for jobs the adopter's **own** app
216
+ * capability declares and its own script exports. Looking for it here finds a case this file will never
217
+ * see.
218
+ *
219
+ * A binding with no `job` or no `className` is not writable: wrangler rejects a `workflows` entry missing
220
+ * either, so emitting a partial one would trade a failed request for a config that will not load.
221
+ * `capabilities/requiredBindings.test.ts` is what makes that a build failure rather than a surprise.
222
+ *
223
+ * **Each refusal says why.** Returning a bare `undefined` is what let `pithy upgrade` count these as
224
+ * written (#318): the caller had nothing to distinguish "wrote it" from "declined", so it reported the
225
+ * plan. The reason is the operator's next step, in their words — the missing field, or the missing
226
+ * project name.
227
+ */
228
+ function workflowEntry(scope: BindingScope, binding: BindingSpec): WorkflowBindingEntry | { reason: string } {
229
+ const { project, env, capability } = scope;
230
+ if (project === undefined) return { reason: "no project name is resolved" };
231
+ if (!isValidEnvironment(env)) return { reason: `the naming scheme does not accept the environment "${env}"` };
232
+ if (binding.job === undefined || binding.className === undefined) {
233
+ return { reason: `${binding.name} declares no ${binding.job === undefined ? "job" : "className"}` };
234
+ }
235
+ return {
236
+ binding: binding.name,
237
+ name: workflowScriptName({ project, capability, job: binding.job, env }),
238
+ class_name: binding.className,
239
+ script_name: resourceNames(project).env(env).worker(capability),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Whether a stanza already declares a binding. `null` means the kind has no `wrangler.jsonc` array this
245
+ * module writes — a `secret`, a `vectorize` index, a `queue`, an `email` send binding, a `service` — so
246
+ * there is nothing to look for and nothing to report missing.
247
+ *
248
+ * The presence check keys on the **binding name alone**, deliberately. Comparing the derived value too
249
+ * would read an adopter's renamed database, retuned rate limit, or repointed Workflow as a missing
250
+ * binding, and the writer would append a second entry for a binding wrangler already has.
251
+ */
252
+ export function stanzaHasBinding(stanza: WranglerStanza, binding: BindingSpec): boolean | null {
253
+ switch (binding.type) {
254
+ case "d1":
255
+ return (stanza.d1_databases ?? []).some((entry) => entry.binding === binding.name);
256
+ case "kv":
257
+ return (stanza.kv_namespaces ?? []).some((entry) => entry.binding === binding.name);
258
+ case "r2":
259
+ return (stanza.r2_buckets ?? []).some((entry) => entry.binding === binding.name);
260
+ case "ai":
261
+ return stanza.ai !== undefined;
262
+ case "durable_object":
263
+ return (stanza.durable_objects?.bindings ?? []).some((entry) => entry.name === binding.name);
264
+ case "ratelimit":
265
+ return (stanza.ratelimits ?? []).some((entry) => entry.name === binding.name);
266
+ case "workflow":
267
+ return (stanza.workflows ?? []).some((entry) => entry.binding === binding.name);
268
+ default:
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Append one binding's entry to a stanza, in place, unless the binding is already there.
275
+ *
276
+ * The writer emits a binding's **shape** — its name, and whatever the spec and the naming rule can state
277
+ * offline. Resource identity that only provisioning knows stays absent: `database_id`, `id`, and
278
+ * `bucket_name` are filled later by `pithy provision` or the capability's own provisioner. `database_name`
279
+ * is the exception and it is a *proposal*, not an identity — wrangler accepts a D1 entry naming a database
280
+ * that does not exist yet, and an adopter who leaves it alone gets a project-scoped name instead of
281
+ * inventing `db`.
282
+ *
283
+ * Returns {@link BindingWrite} — **what happened**, not what was meant to. The KV namespace title rides
284
+ * on a `written` outcome, when this call wrote a KV binding and a name could be composed: **KV is
285
+ * reported rather than written, because wrangler has nowhere to write it**, since a `kv_namespaces` entry
286
+ * takes `binding`, `id`, `preview_id`, and `remote`, with no title field, so the name lives only in the
287
+ * account.
288
+ */
289
+ export function appendBinding(stanza: WranglerStanza, binding: BindingSpec, scope: BindingScope): BindingWrite {
290
+ const has = stanzaHasBinding(stanza, binding);
291
+ if (has === null) return { outcome: "unsupported" };
292
+ if (has) return { outcome: "present" };
293
+
294
+ switch (binding.type) {
295
+ case "d1": {
296
+ stanza.d1_databases ??= [];
297
+ const name = proposeName(scope, binding.name, "d1");
298
+ stanza.d1_databases.push(
299
+ withRemote({ binding: binding.name, ...(name ? { database_name: name } : {}) }, binding),
300
+ );
301
+ return { outcome: "written" };
302
+ }
303
+ case "kv": {
304
+ stanza.kv_namespaces ??= [];
305
+ stanza.kv_namespaces.push(withRemote({ binding: binding.name }, binding));
306
+ const name = proposeName(scope, binding.name, "kv");
307
+ return name
308
+ ? { outcome: "written", proposed: { binding: binding.name, env: scope.env, name } }
309
+ : { outcome: "written" };
310
+ }
311
+ case "r2":
312
+ stanza.r2_buckets ??= [];
313
+ stanza.r2_buckets.push(withRemote({ binding: binding.name }, binding));
314
+ return { outcome: "written" };
315
+ case "ai":
316
+ // A Worker gets exactly one Workers AI binding, so `ai` is an object rather than an array. An
317
+ // existing one is left alone by the presence check above: it is either this capability's
318
+ // (idempotency) or an adopter's deliberate choice of binding name, and clobbering either would
319
+ // break their Worker.
320
+ stanza.ai = withRemote({ binding: binding.name }, binding);
321
+ return { outcome: "written" };
322
+ case "durable_object":
323
+ // `BindingSpec` refuses a classless DO binding at define and at manifest parse, so this is the
324
+ // second wall rather than the first. It still says why, because a writer that returns nothing
325
+ // teaches its caller to report the plan.
326
+ if (binding.className === undefined)
327
+ return { outcome: "skipped", reason: `${binding.name} declares no className` };
328
+ stanza.durable_objects ??= { bindings: [] };
329
+ stanza.durable_objects.bindings ??= [];
330
+ stanza.durable_objects.bindings.push(withRemote({ name: binding.name, class_name: binding.className }, binding));
331
+ return { outcome: "written" };
332
+ case "ratelimit":
333
+ stanza.ratelimits ??= [];
334
+ stanza.ratelimits.push({
335
+ name: binding.name,
336
+ namespace_id: rateLimitNamespaceId(binding.name),
337
+ simple: { limit: RATE_LIMIT_REQUESTS, period: RATE_LIMIT_PERIOD_SECONDS },
338
+ });
339
+ return { outcome: "written" };
340
+ case "workflow": {
341
+ const entry = workflowEntry(scope, binding);
342
+ if ("reason" in entry) return { outcome: "skipped", reason: entry.reason };
343
+ stanza.workflows ??= [];
344
+ stanza.workflows.push(entry);
345
+ return { outcome: "written" };
346
+ }
347
+ default:
348
+ // `queue`, `email`, `secret`, `service`, and `vectorize`. Nothing is emitted, and for two different
349
+ // reasons: a `secret` has no wrangler array at all, and a `vectorize` entry needs the provisioned
350
+ // `index_name` — wrangler's validator requires it, so a binding-only entry stops `wrangler dev` and
351
+ // `wrangler deploy` both. `capabilities/requiredBindings.test.ts` fails any capability that requires
352
+ // a kind neither this writer nor a provisioner covers. Unreachable: `stanzaHasBinding` already
353
+ // returned `null` for every one of them above.
354
+ return { outcome: "unsupported" };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Register a capability's Durable Object classes in the **top-level** `migrations` array — the class
360
+ * migration tag, distinct from D1 (Kysely) migrations and from the per-environment DO bindings.
361
+ *
362
+ * `new_sqlite_classes`, not `new_classes`, deliberately: a Pithy session object stores its state in
363
+ * SQLite-backed DO storage, and `new_classes` would provision a key-value backend that silently cannot run
364
+ * SQL — the well-known DO footgun.
365
+ *
366
+ * **Every add that brings new classes allocates its own tag, and no tag is ever edited after it is
367
+ * written.** A tag is applied once and remembered by Cloudflare: the next deploy sends only the tags
368
+ * *after* the last one applied. So a class appended into a tag that has already been deployed is sent to
369
+ * nobody — the namespace is never created, and the deploy fails on a binding to a class with no migration
370
+ * behind it. Every class used to merge into a single `v1`, and the comment here said why that was safe:
371
+ * `@pithy-sh/multiplayer` was the only capability shipping a Durable Object, so every add was a first add.
372
+ * #415 made `@pithy-sh/matchmaking` addable, which ends that — and `pithy add multiplayer` → `pithy deploy`
373
+ * → `pithy add matchmaking` is the path its own README recommends.
374
+ *
375
+ * Splitting costs a never-deployed Worker nothing: wrangler applies `v1` then `v2` in order on the first
376
+ * deploy, reaching the identical end state. **Idempotent**, because a class already named by *any* tag is
377
+ * not new — so a re-run allocates nothing and rewrites nothing, which is what `pithy add`'s contract and
378
+ * `pithy upgrade`'s reconcile both depend on.
379
+ */
380
+ export function appendDurableObjectMigrations(config: WranglerStanza, bindings: readonly BindingSpec[]): void {
381
+ const classes = bindings
382
+ .filter((binding) => binding.type === "durable_object" && binding.className !== undefined)
383
+ .map((binding) => binding.className as string);
384
+ if (classes.length === 0) return;
385
+
386
+ config.migrations ??= [];
387
+ // A class any existing tag already names is registered, whether or not that tag has been deployed yet.
388
+ // This is the whole of the idempotency: a second `pithy add` of the same capability finds nothing new
389
+ // and returns without touching the file.
390
+ const registered = new Set(config.migrations.flatMap((migration) => migration.new_sqlite_classes ?? []));
391
+ const fresh = [...new Set(classes)].filter((className) => !registered.has(className));
392
+ if (fresh.length === 0) return;
393
+
394
+ config.migrations.push({ tag: nextMigrationTag(config.migrations), new_sqlite_classes: fresh });
395
+ }
396
+
397
+ /**
398
+ * The next unused `v<n>` tag, counting past whatever is already there.
399
+ *
400
+ * Counting rather than incrementing the last one, because a tag an adopter wrote by hand need not be
401
+ * `v<n>` at all, and a collision would silently merge this step into theirs — the exact failure the split
402
+ * exists to prevent. The loop terminates: each pass tries a strictly larger number against a finite set.
403
+ */
404
+ function nextMigrationTag(migrations: readonly DurableObjectMigration[]): string {
405
+ const taken = new Set(migrations.map((migration) => migration.tag));
406
+ let n = migrations.length + 1;
407
+ while (taken.has(`${DO_MIGRATION_TAG_PREFIX}${n}`)) n += 1;
408
+ return `${DO_MIGRATION_TAG_PREFIX}${n}`;
409
+ }
410
+
411
+ /**
412
+ * Every environment's binding stanza, paired with the environment it *is*: the top-level one (the dev
413
+ * environment) plus each `env.<name>`.
414
+ *
415
+ * The pairing is the point. Both writers used to walk `Object.values(config.env)` and drop the key, which
416
+ * was harmless while an entry carried nothing but its binding — and wrong the moment a name has the
417
+ * environment in it, which is every proposed database name and every Workflow entry.
418
+ */
419
+ export function envStanzas(config: WranglerStanza): { env: string; stanza: WranglerStanza }[] {
420
+ const list: { env: string; stanza: WranglerStanza }[] = [{ env: "dev", stanza: config }];
421
+ for (const [env, stanza] of Object.entries(config.env ?? {})) {
422
+ if (stanza) list.push({ env, stanza });
423
+ }
424
+ return list;
425
+ }