@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,306 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ConflictError, InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import type { SecretDispatcher, SecretProbe } from "@pithy-sh/secrets/src/cli/dispatch";
7
+ import { partialWriteReport } from "@pithy-sh/secrets/src/cli/partialWrite";
8
+ import { secretWriteTargets } from "@pithy-sh/secrets/src/cli/writeTargets";
9
+ import { initialDevSecret } from "@pithy-sh/secrets/src/dev/devSecretsFile";
10
+ import { devSecretPayload } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
11
+ import { mintSecretValue } from "@pithy-sh/secrets/src/mintValue";
12
+ import { isMintableSecret, type SecretRegistry, type SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
13
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
14
+ import type { CliAuditEmit } from "../audit/cliAudit";
15
+ import type { MintStoreSecret } from "../provision/secretBindings";
16
+
17
+ /**
18
+ * **Creating the secrets nobody chooses.**
19
+ *
20
+ * `pithy provision --env staging --yes` used to create three databases and then print three commands for
21
+ * a human to run, each of which generates random bytes. `--yes` had been passed. The registry already
22
+ * said which secrets those were — `devValue`, declared by the capability that owns each one — and only
23
+ * local dev ever read it (#321).
24
+ *
25
+ * **Two creators, because there are two stores and each owns the absence check the other cannot make.**
26
+ * A minted value is created once and never regenerated — a second session secret signs everyone out, a
27
+ * second link key stops verifying links already in inboxes, a second key-encryption key orphans
28
+ * everything sealed under the first — so "only if absent" is the property both have to hold.
29
+ *
30
+ * - {@link storeSecretMinter}, for `cf-secrets-store`. The CLI can read the account's store, so
31
+ * `secretsStoreBindings` asks whether the entry is there and this writes it when it is not.
32
+ * - {@link mintDeclaredSecrets}, for `d1`. The CLI cannot read one: the value is sealed under a master
33
+ * key that never leaves the manager Worker. So it asks each manager (`probe`) and then writes with
34
+ * `create`, which refuses a name already there. See `management/writeSecret.ts`.
35
+ *
36
+ * #321 shipped only the first, and the kit declares no `cf-secrets-store` secret that a random string
37
+ * could satisfy, so nothing the kit ships could reach it. `provision/mintCoverage.test.ts` is the gate
38
+ * that now says so, against the registries the kit actually ships rather than one a test made up.
39
+ *
40
+ * **The value exists in one local and nowhere else.** It is never returned, never logged, never put in an
41
+ * audit event, and never printed by the command that called this. What a run reports is that the secret
42
+ * was created and which entry it went to.
43
+ */
44
+
45
+ /** The slice of the Secrets Store minting needs: write an entry. Never reads, never deletes. */
46
+ export interface MintDestination {
47
+ /** Write a value under `name`. Overwrites in place — which is why the caller checks absence first. */
48
+ put(name: string, value: string): Promise<void>;
49
+ }
50
+
51
+ /** Build the live minter for one environment's Secrets Store. */
52
+ export function storeSecretMinter(options: {
53
+ /** Where the value goes. */
54
+ store: MintDestination;
55
+ /** The environment being provisioned, for the audit trail. Never part of the entry name — the scope owns that. */
56
+ environment: string;
57
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
58
+ audit?: CliAuditEmit;
59
+ }): MintStoreSecret {
60
+ const audit = options.audit ?? (async () => {});
61
+ return async ({ binding, secretName, entry }) => {
62
+ // Defense in depth. `secretsStoreBindings` asks `isMintableSecret` before calling, so arriving here
63
+ // with a supplied secret is a bug — and one that would write a random string where an OAuth client
64
+ // secret was meant, leaving a gap that looks filled in. It refuses instead of inventing.
65
+ if (!isMintableSecret(entry) || entry.devValue === undefined) {
66
+ throw new InternalError({
67
+ message: `Secret '${binding}' declares no value of its own, so nothing may mint one.`,
68
+ detail: `mint called for ${secretName}, whose registry entry has no devValue.`,
69
+ });
70
+ }
71
+ // Through `devSecretPayload`, never restating what it says. A Secrets Store entry is read by the
72
+ // Worker straight off its binding, so what is written here is what the Worker gets — and what the
73
+ // dev secrets file states for the same secret, byte for byte. `initialDevSecret` composes the entry
74
+ // the file would hold; reading it back is the one materialisation every destination shares (#323).
75
+ // This wrote an envelope unconditionally, which is the defect that wave was about, at a new producer.
76
+ const stated = initialDevSecret(entry, mintSecretValue(entry.devValue));
77
+ await options.store.put(secretName, devSecretPayload(entry, secretName, stated).text);
78
+ // The name, the entry, the environment. Never the value, and nothing derived from it.
79
+ await audit({
80
+ environment: options.environment,
81
+ action: "secrets/set",
82
+ outcome: "success",
83
+ severity: "warning",
84
+ resourceType: "secret",
85
+ resourceId: secretName,
86
+ metadata: { name: secretName, binding, kind: "generated" },
87
+ });
88
+ };
89
+ }
90
+
91
+ /** One secret this run accounted for: where it belongs, and where this run actually created it. */
92
+ export interface MintedSecret {
93
+ /** The secret's registry name. Never its value. */
94
+ name: string;
95
+ /** The environments the secret belongs in — one for an `environment` secret, all for a `global` one. */
96
+ environments: ManagedEnvironment[];
97
+ /**
98
+ * The environments this run **created** it in, which is empty on every run after the first.
99
+ *
100
+ * Separate from {@link environments} because the two used to be one field saying only where a request
101
+ * was sent, and "sent" is not "made". A report that cannot tell a creation from a no-op is a report an
102
+ * operator cannot use to answer *did this run generate a production signing key*.
103
+ */
104
+ created: ManagedEnvironment[];
105
+ }
106
+
107
+ /**
108
+ * What a run reports about the secrets it accounted for, one line each — names and environments, never
109
+ * a value.
110
+ *
111
+ * **One renderer for the run that finishes and the run that fails part-way.** Two would let a partial
112
+ * report be phrased as a plan: a secret whose fan-out died before prod has an empty `created` under the
113
+ * old shape, and the line for an empty `created` reads *already in staging, prod* — a sentence about an
114
+ * environment that never received it.
115
+ */
116
+ export function mintReportLines(minted: readonly MintedSecret[]): string[] {
117
+ return minted.map((secret) =>
118
+ secret.created.length > 0
119
+ ? `${secret.name} created in ${secret.created.join(", ")}.`
120
+ : `${secret.name} already in ${secret.environments.join(", ")}.`,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * **Where the report rides out of a run that failed (#324).**
126
+ *
127
+ * A throw part-way through a fan-out leaves environments holding a brand-new signing key, and the
128
+ * return value that would have said so never happens. The report used to be assembled after the
129
+ * delivery loop, so the throw took it with it: the operator was told nothing was created, arrived at
130
+ * the next run's refusal, and had no record of what the previous run had done.
131
+ *
132
+ * The mechanism is `partialWriteReport`'s, not this module's. `dispatchSecretWrite` needed the same
133
+ * thing for the same reason (#325), and a second copy of *how a report survives a throw* is the shape
134
+ * this repository has produced four times over. What stays here is the payload: what a mint run
135
+ * created, which is not what a dispatch run reports.
136
+ */
137
+ const mintReport = partialWriteReport<MintedSecret[]>("pithy.cli.mintReport", (value): value is MintedSecret[] =>
138
+ Array.isArray(value),
139
+ );
140
+
141
+ /**
142
+ * What a failed {@link mintDeclaredSecrets} run created before it failed, in registry order. Empty when
143
+ * the thrown thing carries no report — which is the honest answer for a throw from anywhere else.
144
+ */
145
+ export function mintedBeforeFailure(error: unknown): MintedSecret[] {
146
+ return mintReport.read(error) ?? [];
147
+ }
148
+
149
+ /**
150
+ * The declared registry names this creates: every `d1` secret whose value is arbitrary. Exported so a
151
+ * command that **cannot** create them — `pithy provision`, which runs before the managers necessarily
152
+ * exist — can name them rather than finish quietly leaving them absent.
153
+ */
154
+ export function managerMintedSecrets(registry: SecretRegistry): string[] {
155
+ return Object.keys(registry)
156
+ .sort()
157
+ .filter((name) => isManagerMinted(registry[name]));
158
+ }
159
+
160
+ /** A registry entry this creates: `d1`, mintable, and carrying the declaration a value is minted from. */
161
+ type ManagerMintedEntry = SecretRegistryEntry & { devValue: NonNullable<SecretRegistryEntry["devValue"]> };
162
+
163
+ /** `d1`, mintable, not a keyspace. The one predicate, so the creator and the reporter cannot disagree. */
164
+ function isManagerMinted(entry: SecretRegistryEntry | undefined): entry is ManagerMintedEntry {
165
+ if (!entry) return false;
166
+ if (entry.backend !== "d1") return false;
167
+ // `cf-secrets-store` is the other creator's, and `isMintableSecret` refuses both a supplied secret and
168
+ // a keyspace. The `devValue` re-check is for the type; the predicate is what decides.
169
+ return isMintableSecret(entry) && entry.devValue !== undefined;
170
+ }
171
+
172
+ /**
173
+ * Create every `d1` secret the registry declares mintable, across the environments the project declares.
174
+ *
175
+ * **Ask every environment first, then decide once.** This is the whole shape, and it replaced a
176
+ * per-environment `ensure` that could not hold the property it was written for. `ensure` wrote when a
177
+ * name was absent and skipped silently when it was present — a per-environment answer to a
178
+ * cross-environment question. Concretely, and this happened: a run wrote staging and lost prod; the
179
+ * re-run minted a **second** value, found staging present, skipped it, and wrote the second value into
180
+ * prod. Two environments, two values, no error, and every link signed by one refused by the other.
181
+ *
182
+ * So the decision moves in front of the writes, where the whole picture exists:
183
+ *
184
+ * - **Every target already has it** — nothing is dispatched and, just as importantly, **nothing is
185
+ * minted**. No key material is generated on a re-run at all.
186
+ * - **Every target lacks it** — for a `global` secret, one value is minted and written to each; for an
187
+ * `environment` secret, a fresh value per environment. A staging session key that also signed prod
188
+ * sessions would make the environment boundary decorative.
189
+ * - **Some have it and some do not, and the secret is `global`** — the run **fails**, naming the secret
190
+ * and both sides of the split. This is the state that used to be completed silently. Repairing it is a
191
+ * decision about a live signing key — copy the existing value across, or retire it everywhere and
192
+ * start again — and the consequences differ by secret, so a tool does not get to pick.
193
+ *
194
+ * **The writes use `create`, which raises rather than skipping.** Probing narrows the race but cannot
195
+ * close it: two runs can both see a global secret absent. `create` closes it — the loser is refused at
196
+ * its first write instead of fanning its own value into the environments the winner has not reached.
197
+ * That is also why `ensure` is gone from `management/writeSecret.ts` entirely: a mode whose whole
198
+ * behavior is to be quiet has no safe caller here.
199
+ *
200
+ * `resolveWriteTargets` is the same routing `dispatchSecretWrite` applies, so where a value lands cannot
201
+ * disagree with where `pithy secrets create` puts the same secret.
202
+ *
203
+ * Returns what it accounted for, in registry order. Never a value.
204
+ */
205
+ export async function mintDeclaredSecrets(options: {
206
+ /** The registry to read — every declared secret, of every backend. This picks its own out. */
207
+ registry: SecretRegistry;
208
+ /** Where a write goes: the target environment's manager write-Workflow. */
209
+ dispatcher: SecretDispatcher;
210
+ /** Whether a manager already holds a name. Asked of every target before anything is minted. */
211
+ probe: SecretProbe;
212
+ /** Every environment the project declares. Both the targets and the fan-out set. */
213
+ environments: DeclaredEnvironments | readonly string[];
214
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
215
+ audit?: CliAuditEmit;
216
+ }): Promise<MintedSecret[]> {
217
+ const audit = options.audit ?? (async () => {});
218
+ const declared = [...options.environments] as ManagedEnvironment[];
219
+ const minted: MintedSecret[] = [];
220
+ try {
221
+ for (const name of Object.keys(options.registry).sort()) {
222
+ const entry = options.registry[name];
223
+ if (!isManagerMinted(entry)) continue;
224
+ // Through the one rule (`secretWriteTargets`), the same one `dispatchSecretWrite` asks, so where a
225
+ // minted value lands cannot disagree with where `pithy secrets create` puts the same secret.
226
+ //
227
+ // A `global` secret names **no** environment: it is not narrowed, and the rule refuses a narrowed
228
+ // global write. This used to pass `declared[0]` as a placeholder the routing table ignored — a
229
+ // value invented to satisfy a signature, which is precisely the difference the rule now turns on.
230
+ // An `environment` secret resolves each declared environment as its own target.
231
+ const facts = { name, backend: entry.backend, scope: entry.scope, mode: "create" as const, declared };
232
+ const targets =
233
+ entry.scope === "global"
234
+ ? secretWriteTargets({ ...facts, requested: undefined })
235
+ : declared.flatMap((env) => secretWriteTargets({ ...facts, requested: env }));
236
+
237
+ const absent: ManagedEnvironment[] = [];
238
+ const present: ManagedEnvironment[] = [];
239
+ for (const env of targets) {
240
+ ((await options.probe.probe({ env, name })) ? present : absent).push(env);
241
+ }
242
+
243
+ if (absent.length === 0) {
244
+ minted.push({ name, environments: targets, created: [] });
245
+ continue;
246
+ }
247
+ if (entry.scope === "global" && present.length > 0) {
248
+ // The value itself is what disagrees, and no part of the CLI may look at it — so the report is
249
+ // the split, by environment name, and the repair is the operator's.
250
+ //
251
+ // **One remedy, because there is only one.** This used to offer *"give the others the same
252
+ // value with `pithy secrets create`"* first. For a secret this function creates that names an
253
+ // act with no way to perform it: the value is 256 bits of `crypto.getRandomValues` sealed under
254
+ // a master key that never leaves the manager Worker, so nobody — operator, CLI, or another
255
+ // Worker — can read it back out of the environment that holds it. An operator who reached for
256
+ // the first branch found no first step. The branch that works destroys a live signing key, so
257
+ // it is stated plainly with its cost rather than offered second as the safer-looking option.
258
+ throw new ConflictError({
259
+ message: `Secret '${name}' is global, and only some environments have it.`,
260
+ action: `Its value cannot be copied to ${absent.join(", ")} — it is sealed under a master key no command can read. Remove it everywhere with pithy secrets rm ${name}, then run this again. That destroys a live key: everything signed by the value ${present.join(", ")} holds stops verifying.`,
261
+ detail: `global secret '${name}': present in ${present.join(", ")}, absent in ${absent.join(", ")}`,
262
+ });
263
+ }
264
+
265
+ // Minted here and nowhere earlier. A value generated before absence is known is 256 bits of key
266
+ // material created for a secret that already exists, handed to a Workflow, and discarded unread.
267
+ // One value for a `global` secret, a fresh one per environment otherwise — that is what the two
268
+ // scopes mean, and it is the whole reason this owns the environment loop rather than a caller.
269
+ const shared = entry.scope === "global" ? mintSecretValue(entry.devValue) : undefined;
270
+ // Grown one environment at a time, and put in the report by the first write that lands. Assembled
271
+ // after the loop instead, a throw inside took the whole record with it — the environments already
272
+ // written held a new secret and the run reported nothing created (#324). Pushed *before* the first
273
+ // write it would be a plan: an entry with an empty `created` reads *already in staging, prod*,
274
+ // which is the #321 shape exactly.
275
+ const created: ManagedEnvironment[] = [];
276
+ for (const env of absent) {
277
+ // The dispatcher directly, because `targets` above already came from `resolveWriteTargets` — the
278
+ // routing decision is made once, and this loop only delivers to the environments it found empty.
279
+ await options.dispatcher.dispatch({
280
+ env,
281
+ mode: "create",
282
+ name,
283
+ value: shared ?? mintSecretValue(entry.devValue),
284
+ valueType: entry.valueType,
285
+ rotatable: entry.rotatable,
286
+ });
287
+ if (created.length === 0) minted.push({ name, environments: targets, created });
288
+ created.push(env);
289
+ await audit({
290
+ environment: env,
291
+ action: "secrets/set",
292
+ outcome: "success",
293
+ severity: "warning",
294
+ resourceType: "secret",
295
+ resourceId: name,
296
+ metadata: { name, environments: [env], kind: "generated" },
297
+ });
298
+ }
299
+ }
300
+ } catch (error) {
301
+ // Carried, never replaced: the refusal or the fault is what the operator has to read, and what this
302
+ // run wrote is what makes the remedy in it safe to perform.
303
+ throw mintReport.carry(error, minted);
304
+ }
305
+ return minted;
306
+ }
@@ -0,0 +1,207 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
8
+ import type { CloudflareWorkflowsClient } from "@pithy-sh/cloudflare/src/workflows/workflowsClient";
9
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
10
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
11
+ import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
12
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
13
+ import { parse } from "comment-json";
14
+ import type { CliAuditEmit } from "../audit/cliAudit";
15
+ import { runWrangler } from "../project/wrangler";
16
+ import { capabilityLoadError } from "./loadFailure";
17
+
18
+ /**
19
+ * The live payments provisioner — the Cloudflare + wrangler implementation behind `pithy payments provision`.
20
+ *
21
+ * **Payments provisions less than any other capability, and that is the design.** There is no bucket, no KV
22
+ * namespace, no index, and — this is the part worth stating — **no secret written here**. Four stores' worth
23
+ * of credentials are things a human downloads from four consoles: Apple's `.p8`, Google's service-account
24
+ * JSON, Stripe's key pair, Lemon Squeezy's API key and webhook secret. Nothing can mint them, so nothing here
25
+ * pretends to; they arrive through `pithy secrets set` and this command deploys the worker that reads them.
26
+ * What is left is one prebuilt Workflow host per environment, and the `workflows` binding that `pithy add`
27
+ * cannot write because the deployed Workflow name is per environment.
28
+ *
29
+ * `@pithy-sh/payments` is an **optional** capability, so the CLI must not hard-depend on it. Types come in
30
+ * through type-only imports (erased at build), and every runtime value comes through {@link loadPayments} — a
31
+ * guarded dynamic import that turns "the package isn't installed" into an actionable error rather than an
32
+ * unresolved-module crash.
33
+ */
34
+
35
+ /** The payments runtime surface provisioning needs, loaded from the project's own install. */
36
+ type PaymentsResolveModule = typeof import("@pithy-sh/payments/src/provision/resolvePaymentsConfig");
37
+ type PaymentsCapabilityModule = typeof import("@pithy-sh/payments/src/capability");
38
+ type PaymentsSpecsModule = typeof import("@pithy-sh/payments/src/workflows/specs");
39
+ type PaymentsSubjectModule = typeof import("@pithy-sh/payments/src/data/subject");
40
+
41
+ /** The config type, referenced by type only so the CLI gains no dependency on the package. */
42
+ type PaymentsConfig = import("@pithy-sh/payments/src/config/config").PaymentsConfig;
43
+
44
+ /** Everything `pithy payments` loads out of the optional package, in one guarded import. */
45
+ export type PaymentsModule = PaymentsResolveModule &
46
+ PaymentsCapabilityModule &
47
+ PaymentsSpecsModule &
48
+ PaymentsSubjectModule;
49
+
50
+ /**
51
+ * Load `@pithy-sh/payments` from the project's own install. The one place the optional dependency is
52
+ * resolved, so a project that has not added payments gets one clear instruction instead of a module error
53
+ * from whichever call site happened to run first.
54
+ */
55
+ export async function loadPayments(): Promise<PaymentsModule> {
56
+ try {
57
+ const [resolve, capability, specs, subject] = await Promise.all([
58
+ import("@pithy-sh/payments/src/provision/resolvePaymentsConfig"),
59
+ import("@pithy-sh/payments/src/capability"),
60
+ import("@pithy-sh/payments/src/workflows/specs"),
61
+ // `decodeSubjectReference`, so `pithy payments reconcile --subject` reads a holder through the same
62
+ // strict decoder the rails do rather than a split of its own (#412).
63
+ import("@pithy-sh/payments/src/data/subject"),
64
+ ]);
65
+ return { ...resolve, ...capability, ...specs, ...subject };
66
+ } catch (error) {
67
+ throw capabilityLoadError("payments", "@pithy-sh/payments", error);
68
+ }
69
+ }
70
+
71
+ /** The directory of the prebuilt reconcile worker inside the installed package (holds `wrangler.jsonc`). */
72
+ async function paymentsWorkerDir(): Promise<string> {
73
+ try {
74
+ return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/payments/src/workflows/worker")));
75
+ } catch (error) {
76
+ throw capabilityLoadError("payments", "@pithy-sh/payments/src/workflows/worker", error);
77
+ }
78
+ }
79
+
80
+ /** The per-environment resource ids the reconcile worker binds, resolved by the caller. */
81
+ export interface PaymentsEnvResources {
82
+ /** The app database id for this environment — where the `pithy_payments_*` tables live. */
83
+ appDatabaseId: string;
84
+ /** This environment's secrets database id (`<project>-<env>-secrets`) — holds the rails' credentials. */
85
+ secretsDatabaseId: string;
86
+ }
87
+
88
+ /** Resolve the per-environment resources for the reconcile worker (from the project wrangler + a lookup). */
89
+ export type ResolvePaymentsEnv = (env: ManagedEnvironment) => Promise<PaymentsEnvResources>;
90
+
91
+ export interface CloudflarePaymentsProvisionerOptions {
92
+ cf: CloudflareClients;
93
+ accountId: string;
94
+ /**
95
+ * The project name, from `requireProjectName(await loadProject(projectDir))` — never
96
+ * `resolveProjectName`. The deployed host and the reconcile Workflow both lead with it, and a guessed
97
+ * value dispatches into a Workflow name nothing deployed.
98
+ */
99
+ project: string;
100
+ /** The bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
101
+ apiToken: string;
102
+ /** The CF Secrets Store id holding the per-env master keys (the worker decrypts its credentials with one). */
103
+ storeId: string;
104
+ /** The app's resolved payments config — serialized into the worker's `PAYMENTS_CONFIG` var. */
105
+ paymentsConfig: PaymentsConfig;
106
+ /** Resolve the per-env app DB id and secrets DB id — injected so it is testable and decoupled. */
107
+ resolveEnv: ResolvePaymentsEnv;
108
+ /** The Workflows REST client, for running a pass in a deployed environment on demand. */
109
+ workflows: CloudflareWorkflowsClient;
110
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
111
+ audit?: CliAuditEmit;
112
+ }
113
+
114
+ /** The live payments provisioner. Every step is idempotent, so provisioning is safe to re-run. */
115
+ export class CloudflarePaymentsProvisioner {
116
+ readonly #cf: CloudflareClients;
117
+ readonly #accountId: string;
118
+ readonly #project: string;
119
+ readonly #apiToken: string;
120
+ readonly #storeId: string;
121
+ readonly #paymentsConfig: PaymentsConfig;
122
+ readonly #resolveEnv: ResolvePaymentsEnv;
123
+ readonly #workflows: CloudflareWorkflowsClient;
124
+ readonly #audit: CliAuditEmit;
125
+
126
+ constructor(options: CloudflarePaymentsProvisionerOptions) {
127
+ this.#cf = options.cf;
128
+ this.#accountId = options.accountId;
129
+ this.#project = options.project;
130
+ this.#apiToken = options.apiToken;
131
+ this.#storeId = options.storeId;
132
+ this.#paymentsConfig = options.paymentsConfig;
133
+ this.#resolveEnv = options.resolveEnv;
134
+ this.#workflows = options.workflows;
135
+ this.#audit = options.audit ?? (async () => {});
136
+ }
137
+
138
+ /** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy a Workflow host. */
139
+ async preflight(): Promise<void> {
140
+ if (!(await this.#cf.workers().accountSubdomain())) {
141
+ throw new ValidationError({
142
+ message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
143
+ action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
144
+ });
145
+ }
146
+ }
147
+
148
+ /** Resolve the env's wrangler config from the committed template + provisioned ids, then `wrangler deploy`. */
149
+ async deployWorker(env: ManagedEnvironment): Promise<void> {
150
+ const { paymentsWorkerName, resolvePaymentsConfig } = await loadPayments();
151
+ const { appDatabaseId, secretsDatabaseId } = await this.#resolveEnv(env);
152
+ const dir = await paymentsWorkerDir();
153
+ const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
154
+ const config = resolvePaymentsConfig(template, {
155
+ project: this.#project,
156
+ env,
157
+ appDatabaseId,
158
+ secretsDatabaseId,
159
+ storeId: this.#storeId,
160
+ paymentsConfig: this.#paymentsConfig,
161
+ });
162
+
163
+ const configPath = join(dir, `.wrangler.${env}.json`);
164
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
165
+ try {
166
+ await runWrangler(["deploy", "--config", configPath], {
167
+ cwd: dir,
168
+ env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#accountId },
169
+ });
170
+ await this.#audit({
171
+ environment: env,
172
+ action: "payments/worker_deployed",
173
+ outcome: "success",
174
+ severity: "info",
175
+ resourceType: "cf_worker",
176
+ resourceId: paymentsWorkerName(this.#project, env),
177
+ });
178
+ } catch (error) {
179
+ // Truthful: recorded as it happened, never as it was intended.
180
+ await this.#audit({
181
+ environment: env,
182
+ action: "payments/worker_deployed",
183
+ outcome: "failure",
184
+ severity: "info",
185
+ resourceType: "cf_worker",
186
+ resourceId: paymentsWorkerName(this.#project, env),
187
+ });
188
+ throw error;
189
+ } finally {
190
+ await unlink(configPath).catch(() => {});
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Run a reconciliation pass in a deployed environment and wait for its report.
196
+ *
197
+ * Dispatched by the Workflow's own deployed name rather than through a Worker binding, because this call
198
+ * comes from a terminal rather than from inside the Worker — the CLI has no bindings, only the REST API. The
199
+ * parameters are the same schema the in-Worker dispatcher validates, so a run started here and one started
200
+ * by the cron are the same run.
201
+ */
202
+ async reconcile(env: ManagedEnvironment, params: Record<string, unknown>): Promise<unknown> {
203
+ const { PAYMENTS_CAPABILITY } = await loadPayments();
204
+ const name = resourceNames(this.#project).env(env).workflow(PAYMENTS_CAPABILITY, "reconcile");
205
+ return this.#workflows.dispatchAndPoll(name, params);
206
+ }
207
+ }