@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,126 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
6
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
7
+
8
+ /**
9
+ * The control-plane operations provisioning needs for one resource kind, and the Cloudflare-backed
10
+ * implementation of them.
11
+ *
12
+ * Separate from the orchestration above it because the orchestration is the same for every
13
+ * environment a project has — a branch's ephemeral one and a declared `staging` alike — while this is
14
+ * the one part that talks to an account. Injectable so every test above runs without credentials.
15
+ */
16
+
17
+ /** The control-plane operations provisioning needs for one resource kind. */
18
+ export interface ResourceProvisioner {
19
+ /** Find a resource by its exact name, returning its id, or null when absent (id = uuid/nsId, or the bucket name for r2). */
20
+ find(name: string): Promise<{ id: string } | null>;
21
+ /** Create the resource by name and return its id. */
22
+ create(name: string): Promise<{ id: string }>;
23
+ /** Delete the resource by id. Idempotent — a missing resource is not an error. */
24
+ delete(id: string): Promise<void>;
25
+ }
26
+
27
+ /** One provisioner per provisionable resource kind. */
28
+ export type ResourceProvisioners = Record<FeatureResourceKind, ResourceProvisioner>;
29
+
30
+ /**
31
+ * The default provisioners, backed by the `@pithy-sh/cloudflare` control-plane clients. For D1 and KV the
32
+ * id is the CF-assigned uuid/namespace id; for R2, which has no separate id, the bucket name is the id.
33
+ *
34
+ * **`find` is where a not-found becomes a creation, so it is where the account has to be settled (#378).**
35
+ * Both callers are find-or-create: `provisionEnvironment` creates whatever `find` did not return, and
36
+ * `destroyFeature` reconciles by expected name. An empty listing from an account nothing claims is not
37
+ * an absence, and reading it as one stands a real D1, KV namespace or R2 bucket up in somebody else's
38
+ * account — a creation no re-run can walk back, since the second run finds what the first made. So the
39
+ * account travels with the clients, `find` refuses instead of returning `null`, and the lookup is not
40
+ * even attempted: the round trip's answer could not be believed either way.
41
+ *
42
+ * **Known limitation, R2 only:** the delete here is the plain control-plane one, and R2 refuses to delete
43
+ * a bucket that still holds an object or a dangling multipart upload — so a feature bucket that was
44
+ * written to fails teardown. Emptying it first needs the S3 key pair, which the control-plane clients do
45
+ * not carry (`pithy storage deprovision --storage` takes it as a flag for exactly this reason). Wiring
46
+ * that through the feature lifecycle is the fix; until then, empty the bucket by hand.
47
+ */
48
+ export function cloudflareProvisioners(clients: CloudflareClients, account: ConfirmedAccount): ResourceProvisioners {
49
+ const d1 = clients.d1Provisioner();
50
+ const kv = clients.kvProvisioner();
51
+ const r2 = clients.r2Provisioner();
52
+ return {
53
+ d1: {
54
+ find: async (name) => {
55
+ const found = await findOnConfirmedAccount({
56
+ ...account,
57
+ what: `the ${name} database`,
58
+ find: () => d1.findDatabaseByName(name),
59
+ });
60
+ return found ? { id: found.uuid } : null;
61
+ },
62
+ create: async (name) => ({ id: (await d1.createDatabase(name)).uuid }),
63
+ delete: (id) => d1.deleteDatabase(id),
64
+ },
65
+ kv: {
66
+ find: async (name) => {
67
+ const found = await findOnConfirmedAccount({
68
+ ...account,
69
+ what: `the ${name} KV namespace`,
70
+ find: () => kv.findNamespaceByTitle(name),
71
+ });
72
+ return found ? { id: found.id } : null;
73
+ },
74
+ create: async (name) => ({ id: (await kv.createNamespace(name)).id }),
75
+ delete: (id) => kv.deleteNamespace(id),
76
+ },
77
+ r2: {
78
+ find: async (name) => {
79
+ const found = await findOnConfirmedAccount({
80
+ ...account,
81
+ what: `the ${name} bucket`,
82
+ find: () => r2.findBucketByName(name),
83
+ });
84
+ return found ? { id: found.name } : null;
85
+ },
86
+ create: async (name) => ({ id: (await r2.createBucket(name)).name }),
87
+ delete: (id) => r2.deleteBucket(id),
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Audit actions provisioning records. Creating and — especially — deleting a resource changes real
94
+ * infrastructure, and both run headlessly in CI, so "who deleted this, and what exactly went?" must be
95
+ * answerable after the fact.
96
+ *
97
+ * **Named for provisioning, not for features**, since the same two events are emitted for a declared
98
+ * environment. The event's own `environment` field says which one it was; a `feature/` prefix on a
99
+ * `staging` event would be a sentence contradicting the row it sits in.
100
+ */
101
+ export const ProvisionAuditActions = {
102
+ /** A Cloudflare resource was created. */
103
+ resourceCreated: "provision/resource_created",
104
+ /** A Cloudflare resource was deleted during teardown. */
105
+ resourceDeleted: "provision/resource_deleted",
106
+ } as const;
107
+
108
+ /**
109
+ * Where a provisioning run's audit trail is written. **Not the environment being provisioned.**
110
+ *
111
+ * That environment's database may not exist yet — standing it up is what the command is for — so keying
112
+ * the trail on it would resolve nothing and silently drop every creation event. And a feature's is
113
+ * deleted by `destroy`, so a record written there dies with the thing it was recording. Both defeat the
114
+ * point of auditing a headless CI run.
115
+ *
116
+ * It is a **routing** choice and never an `actedOn` claim: each event states the environment it acted on
117
+ * in its own `environment` field, which is where the real answer is known.
118
+ */
119
+ export const AUDIT_DESTINATION_ENV = "dev";
120
+
121
+ /** The resource kind recorded on a provisioning audit event. */
122
+ export const AUDIT_RESOURCE_TYPE: Record<FeatureResourceKind, string> = {
123
+ d1: "cf_d1",
124
+ kv: "cf_kv",
125
+ r2: "cf_r2",
126
+ };
@@ -0,0 +1,149 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import type { ProvisionScope, SecretNameScope } from "@pithy-sh/core/src/naming/provisionScope";
6
+ import { isSecretsCapability } from "@pithy-sh/secrets/src/capability";
7
+ import { isMintableSecret, type SecretRegistry, type SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
8
+ import { aggregateSecretRegistries } from "@pithy-sh/secrets/src/sharedSecretsStore";
9
+
10
+ /**
11
+ * **The `secrets_store_secrets` stanza nothing wrote.**
12
+ *
13
+ * `pithy add` deliberately does not write a `secret` binding, and the reason in
14
+ * `core/src/capability/bindings.ts` is sound: the entry needs a `store_id` and a `secret_name` that do
15
+ * not exist until an account has been reached, so "telling anyone to add one of these to
16
+ * `wrangler.jsonc` sends them somewhere the value does not exist". The defect was that having deferred
17
+ * it, nothing came back — the skip was a decision about *when* and was implemented as a decision about
18
+ * *whether*. A Worker deployed without `SECRETS_ENCRYPTION_KEYS` and failed at its first request.
19
+ *
20
+ * Provisioning is the step being deferred to: it is when the store id is in hand and the entry
21
+ * certainly exists. The entry **name** comes from the scope, so a feature binds its own master key and
22
+ * `staging` binds staging's — the join key is the binding, and the binding never changes.
23
+ *
24
+ * **`dev` never gets a stanza, and that is deliberate rather than an omission.** Local dev materialises
25
+ * every `cf-secrets-store` secret into the generated `.dev.vars` (#179), so a stanza there would name
26
+ * store entries a local run never reads. Provisioning only ever writes `scope.stanza`, which is a
27
+ * deployed or feature environment and never `dev`.
28
+ */
29
+
30
+ /** One `secrets_store_secrets` entry, complete — wrangler rejects an entry missing any of the three. */
31
+ export interface SecretStoreBinding {
32
+ /** The Worker binding name, which is the registry key: the same name every read site uses. */
33
+ binding: string;
34
+ /** The account's one Secrets Store. */
35
+ store_id: string;
36
+ /** The entry inside it, named for this scope. */
37
+ secret_name: string;
38
+ }
39
+
40
+ /**
41
+ * The secret registry a Worker composes, or `null` when it composes no secrets capability at all.
42
+ *
43
+ * **The aggregate, not the secrets capability's own slice.** `aggregateSecretRegistries` is the exact
44
+ * call the Worker makes at composition: every capability contributes the secrets it owns, and
45
+ * `CONNECTION_KEY_ENCRYPTION_KEY` is the adopter's `app` declaration rather than something re-typed into
46
+ * `secrets({ registry })`. Reading the slice bound the master key and nothing else — so a
47
+ * `cf-secrets-store` secret declared by auth, or by the adopter's own capability, got a binding from no
48
+ * command at all, and the Worker booted healthy and failed at the first read of it. That is the half of
49
+ * #238 the master key hid, and it is the same resolution `pithy seed` already uses (`devSecrets/targets`).
50
+ *
51
+ * The `secrets` capability stays the gate. A Worker that declares a `cf-secrets-store` secret and
52
+ * composes no `secrets` has no store to read it from and no `SECRETS` binding to reach one, so there is
53
+ * nothing there to bind.
54
+ */
55
+ export function workerSecretRegistry(capabilities: readonly Capability[]): SecretRegistry | null {
56
+ if (!capabilities.some(isSecretsCapability)) return null;
57
+ return aggregateSecretRegistries(capabilities);
58
+ }
59
+
60
+ /**
61
+ * The registry entries that need a `secrets_store_secrets` binding — one predicate, so a stanza writer
62
+ * and a stanza reader cannot disagree about what belongs in one.
63
+ *
64
+ * **A keyspace is skipped.** It has no single value and therefore no single entry; its members are
65
+ * written one key at a time by the application that mints them, and a binding under the bare name would
66
+ * address an entry nothing ever writes.
67
+ */
68
+ export function boundSecretNames(registry: SecretRegistry): string[] {
69
+ return Object.entries(registry)
70
+ .filter(([, entry]) => entry.backend === "cf-secrets-store" && !entry.keyed)
71
+ .map(([binding]) => binding);
72
+ }
73
+
74
+ /** One secret a run is about to create: what the Worker binds it as, what it is called here, what it is. */
75
+ export interface MintTarget {
76
+ /** The Worker binding name, which is the registry key. */
77
+ binding: string;
78
+ /** The store entry to write, named for this scope. */
79
+ secretName: string;
80
+ /** The registry entry — carries how the value is minted. */
81
+ entry: SecretRegistryEntry;
82
+ }
83
+
84
+ /**
85
+ * Create one declared-but-absent secret. Injected, so the orchestration here is tested without an
86
+ * account, and so a caller with no store credentials simply passes nothing and mints nothing.
87
+ *
88
+ * It resolves once the value is written and readable. It never returns the value, and nothing here ever
89
+ * sees one.
90
+ */
91
+ export type MintStoreSecret = (target: MintTarget) => Promise<void>;
92
+
93
+ /**
94
+ * Every `cf-secrets-store` secret a registry declares, as a complete binding named for `scope`.
95
+ *
96
+ * Which secrets those are is {@link boundSecretNames}' answer, shared with the reader that reports a
97
+ * stanza missing one — the writer and the check cannot disagree about what belongs in a stanza.
98
+ *
99
+ * `exists` decides what is bound rather than what is declared. Cloudflare rejects a deploy whose
100
+ * `secrets_store_secrets` entry names a secret that is not there, so binding a declared-but-unwritten
101
+ * secret would turn a missing value into a failed deploy of the whole Worker. The caller reports what
102
+ * it left out.
103
+ *
104
+ * **`mint` is what stops provisioning asking a human to generate random bytes (#321).** A secret whose
105
+ * registry entry says its value is arbitrary has no decision in it, and this is the moment the tool
106
+ * already knows the value is absent. So it is created here and bound in the same pass. Three properties
107
+ * hold it in place, and each has a test:
108
+ *
109
+ * - **Absent first.** `exists` is consulted before `mint`, always. Replacing a live key-encryption key
110
+ * orphans every value sealed under it, so creating a missing secret and replacing an existing one are
111
+ * different acts and only the first happens here. A re-run mints nothing.
112
+ * - **Declared first.** {@link isMintableSecret} gates it. A supplied secret stays in `missing`, named
113
+ * on its own line, because a random string there authenticates against nothing.
114
+ * - **This backend only.** The Secrets Store answers *does this exist* authoritatively, which is what
115
+ * makes "never regenerate" checkable. A `d1` secret's value is sealed under the master key inside the
116
+ * manager Worker, and the CLI has no such check for one — so nothing here mints one.
117
+ */
118
+ export async function secretsStoreBindings(options: {
119
+ /** The registry to read — one Worker's own, so a Worker gets only what it declares. */
120
+ registry: SecretRegistry;
121
+ /** The scope: what each entry is called in this environment. */
122
+ scope: ProvisionScope;
123
+ /** The account's one Secrets Store id. */
124
+ storeId: string;
125
+ /** Whether an entry of that name is in the store. */
126
+ exists: (name: string) => Promise<boolean>;
127
+ /** Creates a declared-but-absent mintable secret. Omitted means create nothing, and report as before. */
128
+ mint?: MintStoreSecret;
129
+ }): Promise<{ bound: SecretStoreBinding[]; missing: string[]; minted: string[] }> {
130
+ const bound: SecretStoreBinding[] = [];
131
+ const missing: string[] = [];
132
+ const minted: string[] = [];
133
+ for (const binding of boundSecretNames(options.registry)) {
134
+ const entry = options.registry[binding] as SecretRegistry[string];
135
+ const secretName = options.scope.secretEntry(binding, entry.scope as SecretNameScope);
136
+ let present = await options.exists(secretName);
137
+ if (!present && options.mint && isMintableSecret(entry)) {
138
+ await options.mint({ binding, secretName, entry });
139
+ minted.push(binding);
140
+ present = true;
141
+ }
142
+ if (present) {
143
+ bound.push({ binding, store_id: options.storeId, secret_name: secretName });
144
+ } else {
145
+ missing.push(binding);
146
+ }
147
+ }
148
+ return { bound, missing, minted };
149
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+
6
+ /**
7
+ * The account's one Secrets Store, as provisioning needs it.
8
+ *
9
+ * A Cloudflare account has exactly one, flat and unpartitionable, so the entry name is the only
10
+ * partition there is — which is why every name here comes from a {@link ProvisionScope} and never from
11
+ * a caller's string. Behind a seam so the orchestration above is tested without credentials.
12
+ */
13
+ export interface SecretsStore {
14
+ /** The store's id — the `store_id` every `secrets_store_secrets` entry carries. */
15
+ readonly storeId: string;
16
+ /** Is there an entry of this name? */
17
+ exists(name: string): Promise<boolean>;
18
+ /** Write a value under `name`. Overwrites in place; never deletes first. */
19
+ put(name: string, value: string): Promise<void>;
20
+ /** Delete an entry if it is there. Resolves `true` when something was removed. */
21
+ remove(name: string): Promise<boolean>;
22
+ }
23
+
24
+ /** The live store, over the `@pithy-sh/cloudflare` control-plane client. */
25
+ export function cloudflareSecretsStore(clients: CloudflareClients, storeId: string): SecretsStore {
26
+ const store = clients.secrets(storeId);
27
+ return {
28
+ storeId,
29
+ exists: (name) => store.exists(name),
30
+ put: (name, value) => store.putSecret(name, value),
31
+ remove: (name) => store.deleteSecretIfPresent(name),
32
+ };
33
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { basename } from "node:path";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
7
+ import { discoverWorkers } from "../project/workers";
8
+ import { readOptionalWranglerConfig } from "../project/wrangler";
9
+
10
+ /**
11
+ * **Which of an environment's declared bindings still have no resource behind them.**
12
+ *
13
+ * The state this reads is the one #240 was reported from: a project scaffolded, wired and migrated by
14
+ * pithy, whose `wrangler.jsonc` declares `"database_name": "<project>-staging-db"` and no
15
+ * `database_id`, in every environment. `pithy deploy --env staging` used to hand that straight to
16
+ * wrangler, which failed on a binding the adopter never knew they were meant to create — after `init`,
17
+ * `add`, `migrate` and `dev` had all succeeded.
18
+ *
19
+ * So the answer is read from files, offline, and used twice: `pithy deploy` refuses with the command to
20
+ * run, and `pithy doctor` reports it without being asked. One reader, so the two can never disagree
21
+ * about what "provisioned" means.
22
+ */
23
+
24
+ /** One binding an environment declares and has no resource for. */
25
+ export interface UnprovisionedBinding {
26
+ /** The Worker's `apps/<name>` directory. */
27
+ worker: string;
28
+ /** The Cloudflare resource kind the binding needs. */
29
+ kind: FeatureResourceKind;
30
+ /** The binding name in the Worker env, e.g. `DB`. */
31
+ binding: string;
32
+ }
33
+
34
+ /** One binding entry as it appears in a wrangler binding array; only its kind's id field is populated. */
35
+ interface RawBinding {
36
+ binding?: string;
37
+ database_id?: string;
38
+ id?: string;
39
+ bucket_name?: string;
40
+ }
41
+
42
+ /** The slice of a `wrangler.jsonc` this reads: the environment stanzas and their binding arrays. */
43
+ interface RawWrangler {
44
+ env?: Record<
45
+ string,
46
+ | {
47
+ d1_databases?: RawBinding[];
48
+ kv_namespaces?: RawBinding[];
49
+ r2_buckets?: RawBinding[];
50
+ }
51
+ | undefined
52
+ >;
53
+ }
54
+
55
+ /**
56
+ * A value is an id only if it is a non-empty, non-placeholder string.
57
+ *
58
+ * The same rule `pithy env` reports `provisioned` by. A scaffold leaves `<database_id>` behind and an
59
+ * adopter leaves `""`; both look filled in to a truthiness check and neither is something wrangler will
60
+ * deploy against.
61
+ */
62
+ function isId(value: string | undefined): boolean {
63
+ if (value === undefined) return false;
64
+ const trimmed = value.trim();
65
+ return trimmed !== "" && !/^<.+>$/.test(trimmed) && !/placeholder/i.test(trimmed);
66
+ }
67
+
68
+ /**
69
+ * Every binding one environment declares across the project's Workers that has no id yet, in worker
70
+ * then binding order. A Worker with no stanza for that environment declares nothing there and is
71
+ * skipped; a `wrangler.jsonc` that will not open is skipped too, because this reader gates a deploy and
72
+ * a config that cannot be read has its own, better error waiting one step later.
73
+ */
74
+ export async function unprovisionedBindings(projectDir: string, env: string): Promise<UnprovisionedBinding[]> {
75
+ const missing: UnprovisionedBinding[] = [];
76
+ for (const target of await discoverWorkers(projectDir)) {
77
+ if (target.hasWrangler === false) continue;
78
+ const raw = (await readOptionalWranglerConfig(target.dir).catch(() => null)) as RawWrangler | null;
79
+ const stanza = raw?.env?.[env];
80
+ if (!stanza) continue;
81
+ const worker = basename(target.dir);
82
+ for (const entry of stanza.d1_databases ?? [])
83
+ if (!isId(entry.database_id)) missing.push({ worker, kind: "d1", binding: entry.binding ?? "" });
84
+ for (const entry of stanza.kv_namespaces ?? [])
85
+ if (!isId(entry.id)) missing.push({ worker, kind: "kv", binding: entry.binding ?? "" });
86
+ for (const entry of stanza.r2_buckets ?? [])
87
+ if (!isId(entry.bucket_name)) missing.push({ worker, kind: "r2", binding: entry.binding ?? "" });
88
+ }
89
+ return missing;
90
+ }
91
+
92
+ /** How a report names one unprovisioned binding: the Worker, the binding, and the kind behind it. */
93
+ export function describeUnprovisioned(missing: readonly UnprovisionedBinding[]): string {
94
+ return missing.map((entry) => `${entry.worker}.${entry.binding} (${entry.kind})`).join(", ");
95
+ }
96
+
97
+ /**
98
+ * Refuse to deploy into an environment whose bindings have no resources, naming the command that
99
+ * creates them.
100
+ *
101
+ * **Deploy refuses; it does not provision.** A deploy that silently creates account resources is hard
102
+ * to review, and the resources it would create are the long-lived ones. The refusal is the whole
103
+ * improvement: the failure moves from inside wrangler, on a field nobody wrote, to one sentence naming
104
+ * the missing binding and the command that fills it.
105
+ */
106
+ export async function assertEnvironmentProvisioned(projectDir: string, env: string): Promise<void> {
107
+ const missing = await unprovisionedBindings(projectDir, env);
108
+ if (missing.length === 0) return;
109
+ throw new ValidationError({
110
+ message: `${env} declares bindings with no Cloudflare resource behind them: ${describeUnprovisioned(missing)}.`,
111
+ action: `Run pithy provision --env ${env} --yes, then deploy.`,
112
+ detail: "wrangler validates a binding's id before it deploys, so this would have failed inside wrangler.",
113
+ });
114
+ }
@@ -0,0 +1,220 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import type { ProvisionScope } from "@pithy-sh/core/src/naming/provisionScope";
7
+ import { parse } from "comment-json";
8
+ import type { FeatureResource } from "../feature/manifest";
9
+ import { writeJsonc } from "../project/jsonc";
10
+ import { absolutizePaths, provisionConfigPath } from "./featureConfig";
11
+ import type { SecretStoreBinding } from "./secretBindings";
12
+
13
+ /**
14
+ * After provisioning stands up an environment's D1/KV/R2 resources, their ids must land in each
15
+ * Worker's `wrangler.jsonc` under `env.<name>` — that is exactly where the shared `migrate`/`seed`
16
+ * remote drivers, and `wrangler deploy --env <name>`, read each binding's id from. This writes them
17
+ * there, upserting by binding name and preserving every comment in the JSONC (a `pithy.config` output,
18
+ * per CLAUDE.md).
19
+ *
20
+ * **One writer, taking the scope.** The stanza key is `scope.stanza` and every name is the same
21
+ * scope's, so the file this writes into and the names it writes cannot come from two different
22
+ * decisions — which is how a feature-named resource could once be written into a declared environment's
23
+ * stanza.
24
+ */
25
+
26
+ /** One binding-id entry keyed by binding, plus the id field that resource kind uses in wrangler.jsonc. */
27
+ interface BindingEntry {
28
+ binding: string;
29
+ [field: string]: string;
30
+ }
31
+
32
+ /** One `services` entry: the binding name and the Worker script it resolves to in this environment. */
33
+ export interface ServiceEntry {
34
+ /** The binding name in the Worker env (e.g. `BOARD`). */
35
+ binding: string;
36
+ /** The script name that binding reaches in this scope. */
37
+ service: string;
38
+ }
39
+
40
+ /** The env stanza slice provisioning writes: the Worker's own name, its binding arrays, and its services. */
41
+ interface EnvBindings {
42
+ name?: string;
43
+ d1_databases?: BindingEntry[];
44
+ kv_namespaces?: BindingEntry[];
45
+ r2_buckets?: BindingEntry[];
46
+ services?: ServiceEntry[];
47
+ secrets_store_secrets?: SecretStoreBinding[];
48
+ }
49
+
50
+ /**
51
+ * Upsert a binding's fields into a binding array **in place**, so comment-json's array-internal
52
+ * comments (stored as symbol-keyed properties on the array object) survive — a `filter()` would return
53
+ * a plain array and silently drop them. A matching binding's fields are updated on the existing entry;
54
+ * otherwise the entry is pushed.
55
+ */
56
+ function upsertByBinding(entries: BindingEntry[], binding: string, fields: Record<string, string>): void {
57
+ const existing = entries.find((entry) => entry.binding === binding);
58
+ if (existing) Object.assign(existing, fields);
59
+ else entries.push({ binding, ...fields });
60
+ }
61
+
62
+ /** The `EnvBindings` keys holding a binding array — the only ones an id is upserted into. */
63
+ type BindingArrayKey = "d1_databases" | "kv_namespaces" | "r2_buckets";
64
+
65
+ /**
66
+ * The wrangler key and the fields provisioning owns for each resource kind.
67
+ *
68
+ * **D1 carries its name as well as its id**, and that is not decoration: `pithy add` proposes a
69
+ * `database_name` offline, before any account has been reached, and provisioning is the step that makes
70
+ * the proposal true. Writing only the id would leave a stanza asserting one name while addressing a
71
+ * database that may carry another — the two must be written by the same step or they drift.
72
+ */
73
+ const KIND_TO_WRANGLER: Record<
74
+ FeatureResource["kind"],
75
+ { array: BindingArrayKey; fields: (r: FeatureResource) => Record<string, string> }
76
+ > = {
77
+ d1: { array: "d1_databases", fields: (r) => ({ database_name: r.name, database_id: r.id }) },
78
+ kv: { array: "kv_namespaces", fields: (r) => ({ id: r.id }) },
79
+ r2: { array: "r2_buckets", fields: (r) => ({ bucket_name: r.id }) },
80
+ };
81
+
82
+ /**
83
+ * Write one Worker's whole `env.<scope.stanza>` stanza: the resource ids it declares, the script name it
84
+ * deploys under in this scope, and each of its `service` bindings retargeted at this scope's copy of the
85
+ * callee.
86
+ *
87
+ * The stanza is created when absent and reused when present, so this is also what makes provisioning the
88
+ * creator of a stanza for an environment declared after the project was scaffolded. Idempotent, and every
89
+ * comment in the file survives the round trip.
90
+ */
91
+ async function editStanza(
92
+ workerDir: string,
93
+ stanzaKey: string,
94
+ /**
95
+ * **`source` decides the file, not the caller.** A declared environment's ids are read in a pull
96
+ * request, so they go into the tracked `wrangler.jsonc`. A feature's are one job's output, so they go
97
+ * into the generated config under the already-ignored `.wrangler/` — regenerated from the tracked
98
+ * file every run, so it can never drift from it, and never making it dirty. That is what makes "a CI
99
+ * run never commits back" a property of the code rather than a note in a runbook.
100
+ */
101
+ source: boolean,
102
+ mutate: (stanza: EnvBindings) => void,
103
+ ): Promise<string> {
104
+ const raw = await readFile(join(workerDir, "wrangler.jsonc"), "utf8");
105
+ const config = parse(raw) as unknown as { env?: Record<string, EnvBindings | undefined> };
106
+
107
+ config.env ??= {};
108
+ const stanza: EnvBindings = config.env[stanzaKey] ?? {};
109
+ config.env[stanzaKey] = stanza;
110
+
111
+ mutate(stanza);
112
+
113
+ // One resolver for "which file?", shared with what the command reports (#251). A run states where it
114
+ // wrote and whether that file is committed; a report computing the path a second time is a sentence
115
+ // that can disagree with the write it describes.
116
+ const destination = provisionConfigPath(workerDir, source);
117
+ if (!source) {
118
+ // Generated, so every path in it is rewritten against the directory it came from — wrangler
119
+ // resolves a config's paths relative to the config, and this one lives two levels deeper.
120
+ absolutizePaths(config as Record<string, unknown>, workerDir);
121
+ await mkdir(dirname(destination), { recursive: true });
122
+ await writeJsonc(destination, config);
123
+ return destination;
124
+ }
125
+
126
+ // Through the one JSONC printer (#249), never a raw `stringify`. `comment-json` puts every array
127
+ // element on its own line; the project's own scaffolded Biome collapses a short one — so a config
128
+ // written the other way fails the pre-commit hook this CLI installed. `writeJsonc` also keeps an
129
+ // adopter's hand-expanded objects expanded, which matters most here: this file is edited in place on
130
+ // every provision, and a two-line change buried in a whole-file reformat is a change nobody reviewed.
131
+ await writeJsonc(destination, config);
132
+ return destination;
133
+ }
134
+
135
+ /**
136
+ * Upsert the `secrets_store_secrets` entries provisioning owns into one Worker's `env.<stanza>`.
137
+ *
138
+ * Separate from {@link applyProvisionedEnv} because two commands reach it for different reasons.
139
+ * `pithy provision` writes the whole stanza in either mode; `pithy secrets provision`
140
+ * writes only this, for a project whose resources are already in place and whose store entries have
141
+ * just been created — the five cases `ensureSecretsStoreId` cannot resolve at `add` time, and every
142
+ * project that predates the stanza existing at all.
143
+ *
144
+ * **Only the entries it owns.** An adopter's hand-added binding this registry does not declare is left
145
+ * exactly where it is.
146
+ */
147
+ export async function applySecretBindings(
148
+ workerDir: string,
149
+ stanzaKey: string,
150
+ entries: readonly SecretStoreBinding[],
151
+ ): Promise<void> {
152
+ if (entries.length === 0) return;
153
+ // Always the tracked file: only `pithy secrets provision` calls this directly, and it acts on the
154
+ // environments a project deploys to. A feature's stanza is written by the scope-driven writer below.
155
+ await editStanza(workerDir, stanzaKey, true, (stanza) => {
156
+ stanza.secrets_store_secrets ??= [];
157
+ for (const entry of entries) {
158
+ const existing = stanza.secrets_store_secrets.find((candidate) => candidate.binding === entry.binding);
159
+ if (existing) Object.assign(existing, entry);
160
+ else stanza.secrets_store_secrets.push({ ...entry });
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Write one Worker's stanza, and hand back **the path that was written** — the tracked `wrangler.jsonc`
167
+ * or the generated artifact, as the scope decided. The caller reports it, so what a run says it wrote is
168
+ * what the writer wrote rather than a second computation of the same rule (#251).
169
+ */
170
+ export async function applyProvisionedEnv(options: {
171
+ /** The Worker's directory — the one holding the `wrangler.jsonc` to edit. */
172
+ workerDir: string;
173
+ /** The Worker's deploy name, from its own `wrangler.jsonc`. `scope.worker` turns it into this scope's. */
174
+ worker: string;
175
+ /** The scope: both the stanza written into and the names written in. */
176
+ scope: ProvisionScope;
177
+ /** Only the resources this Worker's own config declares. */
178
+ resources: readonly FeatureResource[];
179
+ /** Only the service bindings this Worker's own config declares, already resolved to this scope. */
180
+ services: readonly ServiceEntry[];
181
+ /**
182
+ * The `secrets_store_secrets` entries this Worker's own registry declares, named for this scope.
183
+ *
184
+ * This is the stanza `pithy add` deliberately could not write and nothing came back for (#238, #239).
185
+ * It is complete by construction — every entry carries its `store_id` and `secret_name` — because a
186
+ * partial one does not degrade: wrangler refuses the whole config.
187
+ */
188
+ secrets: readonly SecretStoreBinding[];
189
+ }): Promise<string> {
190
+ const destination = await editStanza(options.workerDir, options.scope.stanza, options.scope.source, (stanza) => {
191
+ stanza.name = options.scope.worker(options.worker);
192
+ for (const resource of options.resources) {
193
+ const { array, fields } = KIND_TO_WRANGLER[resource.kind];
194
+ // Reuse the existing comment-json array (preserving its comments) or start a fresh one, then
195
+ // mutate in place — never replace it with a filtered plain array, which would strip
196
+ // comment-json's symbols.
197
+ stanza[array] ??= [];
198
+ upsertByBinding(stanza[array], resource.binding, fields(resource));
199
+ }
200
+ if (options.services.length > 0) {
201
+ stanza.services ??= [];
202
+ for (const entry of options.services) {
203
+ const existing = stanza.services.find((candidate) => candidate.binding === entry.binding);
204
+ if (existing) existing.service = entry.service;
205
+ else stanza.services.push({ ...entry });
206
+ }
207
+ }
208
+ });
209
+ if (options.secrets.length > 0) {
210
+ await editStanza(options.workerDir, options.scope.stanza, options.scope.source, (stanza) => {
211
+ stanza.secrets_store_secrets ??= [];
212
+ for (const entry of options.secrets) {
213
+ const existing = stanza.secrets_store_secrets.find((candidate) => candidate.binding === entry.binding);
214
+ if (existing) Object.assign(existing, entry);
215
+ else stanza.secrets_store_secrets.push({ ...entry });
216
+ }
217
+ });
218
+ }
219
+ return destination;
220
+ }