@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,407 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { relative } from "node:path";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { ProvisionScope } from "@pithy-sh/core/src/naming/provisionScope";
8
+ import type { CliAuditEmit } from "../audit/cliAudit";
9
+ import { availableManifests } from "../capabilities/manifests";
10
+ import { honoredDeclineNames } from "../capabilities/reconcile";
11
+ import { provisionableBindings, serviceBindings } from "../feature/bindings";
12
+ import type { FeatureResource } from "../feature/manifest";
13
+ import { migrateProject } from "../migrations/run";
14
+ import { loadProject, loadProjectCloudflare, requireProjectName, type WorkerConfig } from "../project/config";
15
+ import { resolveWorkers } from "../project/workerScope";
16
+ import { seedProject } from "../seed/run";
17
+ import { AUDIT_RESOURCE_TYPE, ProvisionAuditActions, type ResourceProvisioners } from "./resources";
18
+ import type { SecretStoreBinding } from "./secretBindings";
19
+ import { applyProvisionedEnv, type ServiceEntry } from "./wranglerEnv";
20
+
21
+ /**
22
+ * **Standing up one environment's own Cloudflare resources — for any environment a project has.**
23
+ *
24
+ * This was written for ephemeral feature environments and was never generalized, which is the single
25
+ * cause of three reported defects: a declared `staging` got no resources at all (#240), a feature got
26
+ * every resource except its secrets (#239), and a deployed Worker got no Secrets Store bindings (#238).
27
+ * The machinery was always general — it resolves the Worker set, provisions one resource per binding
28
+ * name, writes ids into each Worker's own config, then migrates and seeds. Only the *namer* was
29
+ * feature-shaped, and only the command surface was missing.
30
+ *
31
+ * So the environment is not a parameter here. A {@link ProvisionScope} is, and it carries both the names
32
+ * and the stanza they are written into — see `@pithy-sh/core/src/naming/provisionScope` for why those
33
+ * two were never safe as separate arguments.
34
+ *
35
+ * Idempotent and resumable: every resource is matched by name before it is created, so a re-run reuses
36
+ * what exists, a hand-created resource of the right name is **adopted** rather than duplicated, and a
37
+ * run interrupted by a network hiccup completes on the next attempt.
38
+ */
39
+
40
+ /** A migrate/seed seam so provisioning's orchestration is testable without a live backend. */
41
+ export type BackendRunner = (args: { env: string; projectDir: string }) => Promise<void>;
42
+
43
+ // The migrate names its project for the same reason the seed below does, and one more: the stamp it
44
+ // writes is what refuses a later run from another project. A fresh environment's D1 is brand new, so
45
+ // this run is the one that adopts it — skip the name here and the database stays unowned for good.
46
+ const defaultMigrate: BackendRunner = async ({ env, projectDir }) => {
47
+ // One config load, two facts, both from the project's own root config: the project the brand-new D1 is
48
+ // stamped for, and the account it is created and migrated in. A provisioned environment is remote by
49
+ // definition, so this is the account that decides *whose tenant* the schema lands in (#234).
50
+ const config = await loadProject(projectDir);
51
+ await migrateProject({
52
+ env,
53
+ projectDir,
54
+ project: requireProjectName(config),
55
+ account: loadProjectCloudflare(config) ?? null,
56
+ });
57
+ };
58
+
59
+ // The seed names its project because a fixture can mint Cloudflare Images/Stream assets, and those two
60
+ // account-flat stores carry no name we chose — only the owner in their metadata. `requireProjectName`,
61
+ // the same resolver every provisioned resource name already leads with.
62
+ const defaultSeed: BackendRunner = async ({ env, projectDir }) => {
63
+ const config = await loadProject(projectDir);
64
+ await seedProject({
65
+ env,
66
+ projectDir,
67
+ project: requireProjectName(config),
68
+ account: loadProjectCloudflare(config) ?? null,
69
+ yes: true,
70
+ json: true,
71
+ });
72
+ };
73
+
74
+ /**
75
+ * Where a run's resource ids are recorded **outside** the wrangler stanza, when the scope wants that.
76
+ *
77
+ * A feature has one: its resources are ephemeral and something has to be able to delete them exactly,
78
+ * including after a run that failed between creating a resource and writing the config. A declared
79
+ * environment has none — its record *is* the `wrangler.jsonc` stanza, which is source, reviewed, and
80
+ * long-lived. Optional rather than defaulted to a file, so "this environment keeps no side record" is a
81
+ * decision a caller made rather than a path nobody noticed.
82
+ */
83
+ export interface ProvisionRecord {
84
+ /** Everything a previous run recorded that this scope could legitimately have created. */
85
+ load(): Promise<FeatureResource[]>;
86
+ /** Persist the running set. Called after each resource, so an interrupted run resumes from here. */
87
+ save(resources: FeatureResource[]): Promise<void>;
88
+ }
89
+
90
+ /** One provisioned resource in the report: what it is, and whether this run created it or adopted it. */
91
+ export interface ProvisionedResource extends FeatureResource {
92
+ /** True when this run created the resource; false when it already existed (re-run, or adoption). */
93
+ created: boolean;
94
+ }
95
+
96
+ /**
97
+ * The structured outcome of a provisioning run — the `--json` payload and the human summary source.
98
+ *
99
+ * **No `migrated`/`seeded` pair, for the reason `CreateReport` states (#231).** Both were literal `true`s
100
+ * beside the two `await`s that ran the steps, and both steps throw, so the report's own existence already
101
+ * carried the fact. A constant is not a field.
102
+ */
103
+ export interface ProvisionReport {
104
+ /** The environment provisioned — the scope's stanza. */
105
+ env: string;
106
+ /** Every resource, in provision order, flagged created vs. adopted. */
107
+ resources: ProvisionedResource[];
108
+ /** Each Worker and the script name it deploys under in this environment. */
109
+ workers: { worker: string; name: string }[];
110
+ /** Each service binding and the Worker it now targets in this environment. */
111
+ services: ServiceEntry[];
112
+ /** Every `cf-secrets-store` secret this environment declares, and whether it was bound. */
113
+ secretBindings: ProvisionedSecret[];
114
+ /** Where each Worker's ids were written, project-relative — one entry per Worker, in write order. */
115
+ configs: ProvisionedConfig[];
116
+ /**
117
+ * **Are the files above committed, or ignored?** `true` for a declared environment, whose ids are
118
+ * long-lived source a human reviews in a pull request; `false` for a feature, whose ids are one job's
119
+ * output under the already-ignored `.wrangler/`.
120
+ *
121
+ * One flag now decides which of those a run produces, and a flag that flips whether output is committed
122
+ * will eventually surprise someone. So the run says which it did, here and in the human summary — and
123
+ * a pipeline can read the answer rather than infer it, which is what keeps *a CI build never commits
124
+ * back to the repository* a property a script can assert.
125
+ *
126
+ * One boolean for the whole run rather than one per config: a scope is chosen once, so a per-file copy
127
+ * would be N copies of one fact, and every consumer branch on a disagreement they cannot have.
128
+ */
129
+ committed: boolean;
130
+ }
131
+
132
+ /** One file a provisioning run wrote a Worker's ids into. */
133
+ export interface ProvisionedConfig {
134
+ /** The Worker's own deploy name — its `wrangler.jsonc` `name`. */
135
+ worker: string;
136
+ /** The file written, relative to the project root. */
137
+ path: string;
138
+ /** How many binding ids landed in it. Zero for a Worker that declares no provisionable binding. */
139
+ ids: number;
140
+ }
141
+
142
+ /** One declared Secrets Store secret, and whether the environment now binds it. */
143
+ export interface ProvisionedSecret {
144
+ /** The Worker binding name, which is the registry key. */
145
+ binding: string;
146
+ /** The store entry it resolves to in this environment. */
147
+ entry: string;
148
+ /**
149
+ * True when the entry exists and the binding was written. False when the secret is declared and its
150
+ * entry has never been created — bound anyway, wrangler would refuse the whole config, so one absent
151
+ * value would fail the Worker's deploy rather than one read.
152
+ */
153
+ bound: boolean;
154
+ /**
155
+ * True when **this run** created the value, because the registry declared it may be minted (#321).
156
+ *
157
+ * Reported for the same reason a created resource is distinguished from an adopted one: a run that
158
+ * generated a key-encryption key did something an operator needs to be able to see in the log, and a
159
+ * re-run that found one already there did not. The value itself is nowhere — here or anywhere.
160
+ */
161
+ minted: boolean;
162
+ }
163
+
164
+ /** One Worker as provisioning needs it: where it lives, and what *it* composes. */
165
+ export interface ProvisionWorker {
166
+ /** The Worker's deploy name — its `wrangler.jsonc` `name` — which the scoped script name derives from. */
167
+ name: string;
168
+ /** The Worker's directory — the `wrangler.jsonc` this run writes into, and the `apps/<name>` a sibling's service binding names it by. */
169
+ dir: string;
170
+ /** That Worker's own capabilities, from its `apps/<name>/pithy.config.ts`. */
171
+ capabilities: Capability[];
172
+ /**
173
+ * That Worker's own `pithy.config.ts`. Only `declinedBindings` is read from it — a binding this Worker
174
+ * declines gets no resource created for it, because the decline said the resource is not wanted.
175
+ * Optional: the resolver is a seam, and a caller with no config to give is a Worker declining nothing.
176
+ */
177
+ config?: WorkerConfig;
178
+ }
179
+
180
+ /** Options for {@link provisionEnvironment}. */
181
+ export interface ProvisionEnvironmentOptions {
182
+ /** The project root — where `apps/` lives. */
183
+ projectDir: string;
184
+ /** The scope: what the resources are named, and which `env.<name>` stanza their ids are written into. */
185
+ scope: ProvisionScope;
186
+ /**
187
+ * Every capability the environment spans — the union of each Worker's own `apps/<name>/pithy.config.ts`,
188
+ * deduped by name. It is a union rather than a per-Worker loop because an environment is one
189
+ * environment: `provisionableBindings` dedupes by **binding name**, and sharing is keyed on exactly that
190
+ * — two Workers that both declare `DB` get one database, and a Worker wanting its own declares a
191
+ * different binding.
192
+ */
193
+ capabilities: Capability[];
194
+ /** The provisioners to create through (`cloudflareProvisioners` over live CF clients in a real run). */
195
+ provisioners: ResourceProvisioners;
196
+ /** A side record of what was created, for a scope whose resources are torn down automatically. */
197
+ record?: ProvisionRecord;
198
+ /**
199
+ * Whether to load seed data once the schema is up.
200
+ *
201
+ * **Required, and a word each caller writes down.** A feature environment is created empty and is
202
+ * useless without fixtures, so it always seeds. A declared environment already holds real rows, and
203
+ * "provisioning quietly also seeded staging" is not a default anyone should have to discover. A
204
+ * boolean with a default here would have made those two the same decision made once, by whoever
205
+ * wrote the default.
206
+ */
207
+ seedData: boolean;
208
+ /** Migration runner seam (default: `migrateProject`). */
209
+ migrate?: BackendRunner;
210
+ /** Seed runner seam (default: `seedProject`). */
211
+ seed?: BackendRunner;
212
+ /**
213
+ * Worker-resolution seam (default: {@link resolveWorkers}), so tests fix the worker set. Each entry
214
+ * carries that Worker's **own** capabilities, which is what lets the write step give a Worker only
215
+ * the bindings it declares.
216
+ */
217
+ resolveWorkers?: (projectDir: string) => Promise<ProvisionWorker[]>;
218
+ /**
219
+ * This Worker's `secrets_store_secrets` entries, named for the scope — the stanza `pithy add` could
220
+ * not write. Omitted when no account or store id is in hand, in which case no stanza is written and
221
+ * nothing already there is disturbed.
222
+ */
223
+ secretBindings?: (
224
+ capabilities: Capability[],
225
+ ) => Promise<{ bound: SecretStoreBinding[]; missing: string[]; minted: string[] }>;
226
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
227
+ audit?: CliAuditEmit;
228
+ /**
229
+ * Extra metadata every creation event carries — what makes a feature's trail say *which* feature.
230
+ * The scope knows the names; only the caller knows why this environment exists.
231
+ */
232
+ auditMetadata?: Record<string, unknown>;
233
+ }
234
+
235
+ /**
236
+ * The real worker resolver: every Worker under `apps/`, each with its own capabilities loaded from its
237
+ * `apps/<name>/pithy.config.ts`.
238
+ */
239
+ const defaultResolveWorkers = async (projectDir: string): Promise<ProvisionWorker[]> =>
240
+ (await resolveWorkers({ projectDir })).map((worker) => ({
241
+ name: worker.name,
242
+ dir: worker.dir,
243
+ capabilities: worker.capabilities,
244
+ config: worker.config,
245
+ }));
246
+
247
+ /**
248
+ * Resolve a `service` binding's target to the script name that Worker actually deploys under.
249
+ *
250
+ * A service binding names its target as it appears in `apps/<name>/` (`BindingSpec.service`), but a Worker
251
+ * deploys under its `wrangler.jsonc` `name` — and the two diverge routinely (`pithy init replay` writes
252
+ * `apps/board/wrangler.jsonc` with `"name": "replay-board"`). Scoping the directory name would point the
253
+ * binding at a script nobody deploys: RPC through that binding fails and provisioning reports success. So
254
+ * both sides go through the resolved Worker set, which carries the deploy name, and the directory basename
255
+ * is only the key.
256
+ *
257
+ * A target that matches no Worker is refused rather than guessed: provisioning writes an `env.<name>`
258
+ * stanza only for the Workers it resolved, so nothing else can be scoped correctly, and a silently
259
+ * dangling service name is the exact failure this resolution exists to remove.
260
+ */
261
+ function resolveServiceTarget(workers: readonly ProvisionWorker[], target: string): string {
262
+ const found = workers.find((worker) => worker.name === target || worker.dir.endsWith(`/${target}`));
263
+ if (!found) {
264
+ throw new ValidationError({
265
+ message: `A service binding targets "${target}", which is not one of this project's workers.`,
266
+ action: `Name the target as its apps/<name> directory. Known: ${workers.map((worker) => worker.name).join(", ") || "none"}.`,
267
+ });
268
+ }
269
+ return found.name;
270
+ }
271
+
272
+ /**
273
+ * Provision (or resume provisioning) one environment's Cloudflare resources. For each provisionable
274
+ * binding: compute its name from the scope, adopt the resource if one of that name already exists, else
275
+ * create it, and record it. Then write the ids, the scoped script name, and the retargeted service
276
+ * bindings into **each Worker's own** `wrangler.jsonc`, and run remote migrate + seed (both idempotent).
277
+ * Returns a report; safe to re-run.
278
+ */
279
+ export async function provisionEnvironment(options: ProvisionEnvironmentOptions): Promise<ProvisionReport> {
280
+ const audit = options.audit ?? (async () => {});
281
+ const { scope } = options;
282
+ // Resolve the Workers first. Their deploy names are what every service binding is retargeted at, so an
283
+ // unresolvable target must fail here — before a single Cloudflare resource is created.
284
+ const workers = await (options.resolveWorkers ?? defaultResolveWorkers)(options.projectDir);
285
+
286
+ // **Declines are per Worker, and a resource survives one Worker declining it.** The environment
287
+ // provisions one resource per binding *name* — that is how two Workers share a database — so a binding
288
+ // is skipped only when every Worker that declares it declines it. Resolved through the reconcile
289
+ // engine's own `honoredDeclineNames` so `pithy upgrade` and `pithy provision` cannot come to mean two
290
+ // different things by "declined" (#440).
291
+ const manifests = (await availableManifests(options.projectDir)).manifests;
292
+ const declinedPerWorker = new Map<string, ReadonlySet<string>>();
293
+ for (const worker of workers) {
294
+ declinedPerWorker.set(
295
+ worker.name,
296
+ honoredDeclineNames({ manifests, capabilities: worker.capabilities, workerConfig: worker.config }),
297
+ );
298
+ }
299
+ const wantedSomewhere = new Set(
300
+ workers.flatMap((worker) =>
301
+ provisionableBindings(worker.capabilities, declinedPerWorker.get(worker.name)).map((b) => b.binding),
302
+ ),
303
+ );
304
+ // The union is still the source of the *kinds* — `options.capabilities` spans the environment, and a
305
+ // Worker resolver seam may hand back fewer Workers than that union was built from. Only names no
306
+ // Worker wants are dropped.
307
+ const bindings = provisionableBindings(options.capabilities).filter(
308
+ (binding) => workers.length === 0 || wantedSomewhere.has(binding.binding),
309
+ );
310
+ const services = serviceBindings(options.capabilities).map((service) => ({
311
+ binding: service.binding,
312
+ service: scope.worker(resolveServiceTarget(workers, service.target)),
313
+ }));
314
+
315
+ const recorded: FeatureResource[] = options.record ? await options.record.load() : [];
316
+ const byBinding = new Map(recorded.map((resource) => [`${resource.kind}:${resource.binding}`, resource]));
317
+
318
+ const resources: ProvisionedResource[] = [];
319
+ for (const { binding, kind } of bindings) {
320
+ const name = scope.resource(binding, kind);
321
+ const provisioner = options.provisioners[kind];
322
+ const found = await provisioner.find(name);
323
+ const id = found ? found.id : (await provisioner.create(name)).id;
324
+ const resource: FeatureResource = { kind, binding, name, id };
325
+ byBinding.set(`${kind}:${binding}`, resource);
326
+ await options.record?.save([...byBinding.values()]); // persist after each — a crash mid-run resumes from here.
327
+ resources.push({ ...resource, created: found === null });
328
+
329
+ // Record only a genuine creation; a run that adopted an existing resource changed nothing.
330
+ if (!found) {
331
+ await audit({
332
+ environment: scope.stanza,
333
+ action: ProvisionAuditActions.resourceCreated,
334
+ outcome: "success",
335
+ resourceType: AUDIT_RESOURCE_TYPE[kind],
336
+ resourceId: id,
337
+ metadata: { ...options.auditMetadata, name, binding },
338
+ });
339
+ }
340
+ }
341
+
342
+ // Write the ids, the scoped script name, and the service targets into **each Worker's own**
343
+ // `wrangler.jsonc` — the file wrangler actually reads, and the file `migrate`/`seed` resolve binding ids
344
+ // from. There is no root Worker: every Worker lives in `apps/<name>/` and owns its wrangler config.
345
+ //
346
+ // **A Worker receives only the bindings its own config declares.** The run provisions one resource per
347
+ // binding name across the whole environment (that is how two Workers share a database — same binding
348
+ // name, same resource), but the *wiring* is per Worker: handing a Worker ids for resources it never
349
+ // declared would put bindings in its wrangler config that it has no business holding.
350
+ const secrets: ProvisionedSecret[] = [];
351
+ const configs: ProvisionedConfig[] = [];
352
+ for (const worker of workers) {
353
+ const declared = new Set(
354
+ provisionableBindings(worker.capabilities, declinedPerWorker.get(worker.name)).map((binding) => binding.binding),
355
+ );
356
+ const workerSecrets = (await options.secretBindings?.(worker.capabilities)) ?? {
357
+ bound: [],
358
+ missing: [],
359
+ minted: [],
360
+ };
361
+ const minted = new Set(workerSecrets.minted);
362
+ for (const entry of workerSecrets.bound) {
363
+ secrets.push({
364
+ binding: entry.binding,
365
+ entry: entry.secret_name,
366
+ bound: true,
367
+ minted: minted.has(entry.binding),
368
+ });
369
+ }
370
+ for (const binding of workerSecrets.missing) {
371
+ secrets.push({ binding, entry: scope.secretEntry(binding, "environment"), bound: false, minted: false });
372
+ }
373
+ const written = resources.filter((resource) => declared.has(resource.binding));
374
+ const destination = await applyProvisionedEnv({
375
+ workerDir: worker.dir,
376
+ worker: worker.name,
377
+ scope,
378
+ resources: written,
379
+ secrets: workerSecrets.bound,
380
+ // Likewise: only the service bindings this Worker declares, retargeted at this environment's copy.
381
+ services: serviceBindings(worker.capabilities).map((service) => ({
382
+ binding: service.binding,
383
+ service: scope.worker(resolveServiceTarget(workers, service.target)),
384
+ })),
385
+ });
386
+ // The path the writer wrote, taken from the writer — never recomputed here. A report that names one
387
+ // file while another was edited is the failure the report exists to prevent (#251).
388
+ configs.push({ worker: worker.name, path: relative(options.projectDir, destination), ids: written.length });
389
+ }
390
+
391
+ const migrate = options.migrate ?? defaultMigrate;
392
+ const seed = options.seed ?? defaultSeed;
393
+ // migrate and seed fan out over the Workers themselves, each against its own wrangler.jsonc — the file
394
+ // this run just wrote the environment's binding ids into.
395
+ await migrate({ env: scope.stanza, projectDir: options.projectDir });
396
+ if (options.seedData) await seed({ env: scope.stanza, projectDir: options.projectDir });
397
+
398
+ return {
399
+ env: scope.stanza,
400
+ resources,
401
+ workers: workers.map((worker) => ({ worker: worker.name, name: scope.worker(worker.name) })),
402
+ services,
403
+ secretBindings: secrets,
404
+ configs,
405
+ committed: scope.source,
406
+ };
407
+ }
@@ -0,0 +1,98 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { isAbsolute, join } from "node:path";
5
+ import { FEATURE_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
6
+
7
+ /**
8
+ * **A feature's config is a build artifact, so it is not written where source lives.**
9
+ *
10
+ * A feature's ids could once land in `apps/<worker>/wrangler.jsonc` — a
11
+ * tracked, committed file, and one that cannot be gitignored because it is the project's real config.
12
+ * In CI that is correct as designed: the checkout is throwaway, wrangler reads the stanza, the job
13
+ * ends, nothing is committed. Everywhere else it was an expectation rather than a guarantee. A
14
+ * developer in a feature worktree carried a modified tracked file they had not edited, with nothing
15
+ * saying it must not be committed; `git add -A` put ids for since-deleted resources onto `main`, and
16
+ * `feature destroy` — which reverses every other thing provisioning did — did not reverse the edit.
17
+ *
18
+ * **The rule, and it is a shape rather than a warning: a CI run never writes a tracked file.** So the
19
+ * feature's config is generated beside the Worker's, under `.wrangler/`, which every scaffolded
20
+ * project has ignored since the first release and ignores at any depth. Nothing new to add to a
21
+ * `.gitignore`, nothing for an existing project to adopt, and no `git add -A` that can reach it. It
22
+ * also answers the question `destroy` could not: a feature abandoned without teardown leaves an ignored
23
+ * file in a directory nothing reads, rather than a stranded edit to source.
24
+ *
25
+ * `main` is rewritten to an absolute path because wrangler resolves a config's paths relative to the
26
+ * config file, and this one sits two directories deeper than the file it was generated from.
27
+ */
28
+
29
+ /** Where the generated config for a feature environment lives, under the already-ignored `.wrangler/`. */
30
+ export function featureConfigPath(workerDir: string): string {
31
+ return join(workerDir, ".wrangler", "pithy", "wrangler.feature.jsonc");
32
+ }
33
+
34
+ /**
35
+ * The file a provisioning run writes one Worker's ids into: the tracked `wrangler.jsonc` when they are
36
+ * source, the generated config when they are a build artifact.
37
+ *
38
+ * **One function, because the writer and the report have to agree.** `pithy provision` states the file it
39
+ * wrote and whether it is committed, and a report naming one path while the writer edits another is worse
40
+ * than no report at all — it is a note in a runbook contradicting the code. Both go through here, so
41
+ * "which file?" is answered once, by `ProvisionScope.source`, which is also what answered "what is it
42
+ * called?" and "which stanza?".
43
+ */
44
+ export function provisionConfigPath(workerDir: string, source: boolean): string {
45
+ return source ? join(workerDir, "wrangler.jsonc") : featureConfigPath(workerDir);
46
+ }
47
+
48
+ /**
49
+ * The config file a command should read (or hand to wrangler) for one Worker and one environment.
50
+ *
51
+ * A feature environment resolves to the generated file; everything else to the Worker's own tracked
52
+ * `wrangler.jsonc`. One resolver, so `migrate`, `seed` and `deploy` cannot disagree about which bytes
53
+ * describe the environment they are acting on.
54
+ */
55
+ export function wranglerConfigPath(workerDir: string, env: string): string {
56
+ return provisionConfigPath(workerDir, isSourceEnvironment(env));
57
+ }
58
+
59
+ /**
60
+ * Is this environment's config **source** — the tracked `wrangler.jsonc` — rather than a generated one?
61
+ *
62
+ * The same question `ProvisionScope.source` answers for a provisioning run, asked by a caller that has
63
+ * only an environment name. One predicate rather than two comparisons against a literal, so a command
64
+ * that starts handling feature environments cannot answer it differently from the writer.
65
+ *
66
+ * A project cannot declare `feature` as one of its environments, so the two answers can never both be
67
+ * true of one name — `DeclaredEnvironments` refuses it for exactly this reason.
68
+ */
69
+ export function isSourceEnvironment(env: string): boolean {
70
+ return env !== FEATURE_ENVIRONMENT;
71
+ }
72
+
73
+ /** The path-valued fields wrangler resolves relative to the config file, and this file therefore rewrites. */
74
+ const RELATIVE_PATHS = ["main"] as const;
75
+
76
+ /**
77
+ * Rewrite a generated config's relative paths against the directory it was generated *from*.
78
+ *
79
+ * Wrangler resolves `main` relative to the configuration file's own location, and the generated file
80
+ * sits under `<worker>/.wrangler/pithy/`. Left alone, `"main": "src/index.ts"` would name a file that
81
+ * does not exist and the deploy would fail on it — the exact class of quiet breakage this whole thread
82
+ * is about, so it is fixed here rather than left to the first adopter to hit it.
83
+ *
84
+ * `assets.directory` gets the same treatment when a Worker carries a UI.
85
+ */
86
+ export function absolutizePaths(config: Record<string, unknown>, workerDir: string): void {
87
+ for (const key of RELATIVE_PATHS) {
88
+ const value = config[key];
89
+ if (typeof value === "string" && !isAbsolute(value)) config[key] = join(workerDir, value);
90
+ }
91
+ const assets = config.assets;
92
+ if (assets && typeof assets === "object") {
93
+ const directory = (assets as { directory?: unknown }).directory;
94
+ if (typeof directory === "string" && !isAbsolute(directory)) {
95
+ (assets as { directory?: unknown }).directory = join(workerDir, directory);
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,62 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+
6
+ /**
7
+ * **Which environment `pithy provision` is provisioning — the flag, and nothing else.**
8
+ *
9
+ * Provisioning is one job with two spellings. A declared environment and a branch's differ only in how
10
+ * the target is *named*: from the root `pithy.config.ts`, or from the checked-out branch. That is a flag,
11
+ * not a different verb, and it was only ever two commands because the naming and the destination stanza
12
+ * used to be independent arguments. `ProvisionScope` fused them, so the safety no longer lives in which
13
+ * command was typed.
14
+ *
15
+ * **`--feature` is declared, never inferred.** Nothing here reads a branch, and nothing may: switching
16
+ * mode because "the branch looks like a feature branch" is how someone on `feature/…` provisions the
17
+ * wrong thing while reading a command line that says nothing about it. The branch is where a feature's
18
+ * *name* comes from, once the operator has said `--feature`.
19
+ *
20
+ * **Refused here, at the flag.** This is a pure function over two booleans' worth of input: no config is
21
+ * loaded, no account is resolved, no Cloudflare client exists yet. `pithy provision --env staging
22
+ * --feature` therefore fails the same way outside a project as inside one, with the sentence about the
23
+ * flags rather than a sentence about whatever the next step happened to need.
24
+ */
25
+
26
+ /** The target of a provisioning run, as the flags named it. */
27
+ export type ProvisionMode =
28
+ | {
29
+ /** A declared environment — `staging`, `prod`, whatever the root config lists. */
30
+ readonly kind: "environment";
31
+ /** The `--env` value, still to be checked against the project's declaration. */
32
+ readonly env: string;
33
+ }
34
+ | {
35
+ /** This branch's own ephemeral environment. */
36
+ readonly kind: "feature";
37
+ };
38
+
39
+ /** The two flags this reads. Exactly one of them is required, and passing both is a refusal. */
40
+ export interface ProvisionModeFlags {
41
+ /** `--env <name>`. Absent as `undefined`, and an empty string counts as absent. */
42
+ env?: string | undefined;
43
+ /** `--feature`. */
44
+ feature: boolean;
45
+ }
46
+
47
+ /** Resolve the mode, or refuse: exactly one of `--env` and `--feature`, always. */
48
+ export function requireProvisionMode(flags: ProvisionModeFlags): ProvisionMode {
49
+ const named = flags.env !== undefined && flags.env !== "";
50
+ if (named && flags.feature) {
51
+ throw new ValidationError({
52
+ message: "Pass either --env or --feature, not both.",
53
+ action: "--env <name> provisions an environment the project declares. --feature provisions this branch's.",
54
+ });
55
+ }
56
+ if (named) return { kind: "environment", env: flags.env as string };
57
+ if (flags.feature) return { kind: "feature" };
58
+ throw new ValidationError({
59
+ message: "Provisioning needs an environment to provision.",
60
+ action: "Pass --env <name> for one the project declares, or --feature for this branch's.",
61
+ });
62
+ }
@@ -0,0 +1,96 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
5
+ import { managerMintedSecrets } from "../capabilities/mintSecrets";
6
+ import type { ProvisionMode } from "./mode";
7
+
8
+ /**
9
+ * **What `pithy provision` declares and cannot create, and who — if anyone — can.**
10
+ *
11
+ * A `d1` secret's value is sealed under a master key that lives inside an environment's secrets manager
12
+ * Worker, so only that manager can write one. `pithy provision` runs *before* the managers are
13
+ * necessarily deployed, so it creates none of them. That limit is real and is not a bug to be papered
14
+ * over; finishing quietly was the bug, and #321 closed it by naming the secrets.
15
+ *
16
+ * **What it then named as the fix was true for one mode only (#330).** The line said *run
17
+ * `pithy secrets provision`* whichever mode had been typed. That command iterates the environments the
18
+ * project **declares** and deploys a manager into each. A branch is not declared and gets no manager —
19
+ * deliberately, by #241: a manager is a Worker with its own D1 and its own rotation cron, and one per
20
+ * open pull request is not a thing anybody wants. So for `--feature` the command does nothing at all,
21
+ * and the operator who ran it learned nothing, which is the exact dead end this area exists to remove.
22
+ *
23
+ * **There is no remedy for a feature environment, and that is the sentence rather than a better
24
+ * command.** Every route to one was checked before this was written:
25
+ *
26
+ * - `pithy secrets provision` spans `projectEnvironments` — the declared set. A branch is never in it.
27
+ * - Giving it a `--feature` mode would deploy a manager per branch, which is the design #241 refused.
28
+ * - The CLI cannot write the row itself. The master key is put into the account's Secrets Store and read
29
+ * back by nothing: the store is write-only to this side, which is the premise the whole design rests on.
30
+ * - `seedDevSecrets` writes rows directly, but into a *local* Miniflare D1 from a local file. There is no
31
+ * remote equivalent, and inventing one here would be a second writer for the sealed store.
32
+ *
33
+ * So a shortfall is stated. A branch's environment comes up without these secrets, and an operator is
34
+ * told that in the run that made it rather than by the first request that needs one.
35
+ *
36
+ * **It warns; it does not refuse.** `pithy provision --feature` runs per pull request, in CI, and
37
+ * refusing every one of them for a gap the command cannot close would break the pipeline without moving
38
+ * the problem. A feature environment is still the thing it was for every capability that needs no `d1`
39
+ * secret.
40
+ */
41
+
42
+ /** The `d1` secrets a provisioning run defers, and what creates them — if a command does. */
43
+ export interface PendingSecrets {
44
+ /**
45
+ * The secrets, by name, in registry order. **A fact about the registry, not about the mode**: both
46
+ * modes defer the same set, because both run before any manager exists.
47
+ */
48
+ names: string[];
49
+ /**
50
+ * The command that creates them, or `null` when no command does.
51
+ *
52
+ * A string rather than a boolean so the run prints the command it has instead of composing one, and so
53
+ * a pipeline reading `--json` branches on the same value the sentence is built from.
54
+ */
55
+ remedy: string | null;
56
+ }
57
+
58
+ /**
59
+ * What creates a deferred secret, per mode.
60
+ *
61
+ * **A total record over `ProvisionMode["kind"]`, so a third mode fails the build here** rather than
62
+ * inheriting whichever branch happened to come first — which is precisely how `--feature` came to be
63
+ * told `--env`'s answer. `null` is a mode where nothing does, written down rather than left out.
64
+ */
65
+ const PENDING_SECRET_REMEDY: Record<ProvisionMode["kind"], string | null> = {
66
+ environment: "pithy secrets provision",
67
+ feature: null,
68
+ };
69
+
70
+ /**
71
+ * The deferred secrets for one run, from the registry it provisions for and the mode it was asked in.
72
+ *
73
+ * The predicate is {@link managerMintedSecrets} — the same one the creator uses — so a capability that
74
+ * adds an arbitrary `d1` secret tomorrow is named here without a list being maintained.
75
+ */
76
+ export function pendingSecrets(registry: SecretRegistry, mode: ProvisionMode): PendingSecrets {
77
+ return { names: managerMintedSecrets(registry), remedy: PENDING_SECRET_REMEDY[mode.kind] };
78
+ }
79
+
80
+ /**
81
+ * The human lines: what was deferred, then what to do about it. Empty when nothing was deferred, so a
82
+ * project declaring no arbitrary `d1` secret reads no paragraph about one.
83
+ *
84
+ * The second line is chosen by `remedy` and by nothing else, so the prose and the `--json` field cannot
85
+ * disagree about whether a command exists. Today the one mode with no remedy is `--feature`, which is
86
+ * what the shortfall sentence describes; the record above is what keeps that true.
87
+ */
88
+ export function pendingSecretLines(pending: PendingSecrets): string[] {
89
+ if (pending.names.length === 0) return [];
90
+ return [
91
+ `${pending.names.join(", ")}: not created here — they need a deployed manager.`,
92
+ pending.remedy === null
93
+ ? "A branch gets no manager, and no command creates these for one. This environment comes up without them."
94
+ : `Run ${pending.remedy} to create them.`,
95
+ ];
96
+ }