@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,336 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
7
+ import { CloudflareWorkflowsClient } from "@pithy-sh/cloudflare/src/workflows/workflowsClient";
8
+ import { fromZodError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import { managerWorkerName } from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
10
+ import { type ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
11
+ import { defineCommand } from "citty";
12
+ import { parse } from "comment-json";
13
+ import { createProjectCliAudit } from "../audit/cliAudit";
14
+ import {
15
+ CloudflarePaymentsProvisioner,
16
+ loadPayments,
17
+ type PaymentsEnvResources,
18
+ } from "../capabilities/paymentsProvisioner";
19
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
20
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
21
+ import { applyAppBindings, appWorkflowBindings } from "../project/appBindings";
22
+ import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "../project/config";
23
+ import { envArg, requireManagedEnvironment } from "../project/environment";
24
+ import { projectCapabilities, resolveWorkers } from "../project/workerScope";
25
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
26
+
27
+ /**
28
+ * `pithy payments provision` / `reconcile`.
29
+ *
30
+ * `pithy add payments` writes bindings and touches no Cloudflare account. This command stands up the one
31
+ * thing those bindings point at: the prebuilt reconcile worker that hosts the nightly pass, per environment.
32
+ *
33
+ * **No credential is written here, and that is not an omission.** Apple's `.p8`, Google's service-account key,
34
+ * Stripe's key pair, and Lemon Squeezy's API key and webhook secret are downloaded by a human from four
35
+ * consoles — nothing can mint them. They go in through `pithy secrets set` under
36
+ * `payments-provider-credentials`, and this command deploys the worker that reads them. A provision run
37
+ * before the secrets are set still succeeds; the first pass is what reports the missing rail.
38
+ *
39
+ * `reconcile` runs the same pass on demand, in a deployed environment, and waits for its report. It is the
40
+ * support tool the issue names — "my subscription isn't showing up" is answered by `--subject`, through exactly
41
+ * the steps the cron runs, so an answer here is an answer about production behavior rather than about a
42
+ * script somebody wrote for the occasion.
43
+ */
44
+
45
+ /**
46
+ * The audit emitter for a payments command. Provisioning spans every managed environment at once, so there is
47
+ * no single target env to key the audit database on — `"dev"` is the fallback, matching `pithy storage` and
48
+ * `pithy media`. A no-op when the credentials or the audit capability are not there.
49
+ */
50
+ async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
51
+ // `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
52
+ // single value is true for the run; each event states the environment it acted on.
53
+ return createProjectCliAudit({ projectDir, accountId, apiToken });
54
+ }
55
+
56
+ /** Load the payments capability's resolved catalog from `pithy.config.ts`. */
57
+ async function loadPaymentsConfig(projectDir: string) {
58
+ const { isPaymentsCapability } = await loadPayments();
59
+ // Capabilities live in each Worker's `apps/<name>/pithy.config.ts`; provisioning is one project-wide
60
+ // decision, so the first Worker composing this capability provides it.
61
+ const capability = (await resolveWorkers({ projectDir }).then(projectCapabilities)).find(isPaymentsCapability);
62
+ if (!capability) {
63
+ throw new ValidationError({
64
+ message: "The payments capability is not configured.",
65
+ action: "Add `payments({ rails: { ... }, products: { ... } })` to pithy.config.ts (run `pithy add payments`).",
66
+ });
67
+ }
68
+ return capability.paymentsConfig;
69
+ }
70
+
71
+ /**
72
+ * The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
73
+ *
74
+ * The account is a parameter rather than an ambient, so this cannot resolve before something has
75
+ * established which account the project is for (#206).
76
+ *
77
+ * It also carries **what vouches for the account** (#378). A bare id is what every destructive and
78
+ * creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
79
+ * "I asked an account nothing claims" — the two arrive as one empty listing.
80
+ */
81
+ function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
82
+ account: ConfirmedAccount;
83
+ accountId: string;
84
+ apiToken: string;
85
+ storeId: string;
86
+ } {
87
+ const vars = cloudflareEnv({ account });
88
+ const confirmation = cloudflareAccountConfirmation({ account });
89
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
90
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
91
+ const storeId = vars.SECRETS_STORE_ID ?? "";
92
+ if (!accountId || !apiToken) {
93
+ throw new ValidationError({
94
+ message: "Cloudflare credentials are missing.",
95
+ action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
96
+ });
97
+ }
98
+ if (!storeId) {
99
+ throw new ValidationError({
100
+ message: "The CF Secrets Store id is missing.",
101
+ action:
102
+ "Run pithy add secrets to record SECRETS_STORE_ID (the reconcile worker decrypts the rails' credentials from it).",
103
+ });
104
+ }
105
+ return { account: { accountId, confirmation }, accountId, apiToken, storeId };
106
+ }
107
+
108
+ /** A wrangler env stanza — only the fields the reconcile worker deploy reads from the project's config. */
109
+ interface WranglerStanza {
110
+ d1_databases?: { binding: string; database_id?: string }[];
111
+ env?: Record<string, WranglerStanza | undefined>;
112
+ }
113
+
114
+ /**
115
+ * Resolve the per-environment resources the reconcile worker binds, from the project's `wrangler.jsonc` (the
116
+ * app `DB` id per env) and a live lookup of the env's secrets database. Each missing value throws an
117
+ * actionable error rather than deploying a half-wired worker.
118
+ */
119
+ function buildResolveEnv(
120
+ projectDir: string,
121
+ cf: CloudflareClients,
122
+ /**
123
+ * The project name the secrets database is found by — `<project>-<env>-secrets`. Resolved once by the
124
+ * caller via `requireProjectName`, never guessed: the lookup is by name, so a wrong one either reports
125
+ * a database that "does not exist" or binds another project's secrets store.
126
+ */
127
+ project: string,
128
+ /**
129
+ * The account the secrets database is looked for on, and what vouches for it (#378).
130
+ *
131
+ * The refusal below reads a missing database as "provision it first". Against an account nothing
132
+ * claims, that database is missing because this run asked the wrong account — and the sentence sends
133
+ * an operator to run a provisioning command they have already run.
134
+ */
135
+ account: ConfirmedAccount,
136
+ ): (env: ManagedEnvironment) => Promise<PaymentsEnvResources> {
137
+ return async (env) => {
138
+ const config = parse(await readFile(join(projectDir, "wrangler.jsonc"), "utf8")) as unknown as WranglerStanza;
139
+ const stanza = config.env?.[env];
140
+ if (!stanza) {
141
+ throw new ValidationError({
142
+ message: `wrangler.jsonc has no env.${env} stanza.`,
143
+ action: `Add the ${env} environment to wrangler.jsonc with its DB binding.`,
144
+ });
145
+ }
146
+ const appDatabaseId = stanza.d1_databases?.find((database) => database.binding === "DB")?.database_id;
147
+ if (!appDatabaseId) {
148
+ throw new ValidationError({
149
+ message: `wrangler.jsonc env.${env} has no DB database_id.`,
150
+ action: `Provision the ${env} app database and set its id on the DB binding — the purchase rows live there.`,
151
+ });
152
+ }
153
+ const secretsDb = await findOnConfirmedAccount({
154
+ ...account,
155
+ what: `the ${managerWorkerName(project, env)} database`,
156
+ find: () => cf.d1Provisioner().findDatabaseByName(managerWorkerName(project, env)),
157
+ });
158
+ if (!secretsDb) {
159
+ throw new ValidationError({
160
+ message: `The ${env} secrets database (${managerWorkerName(project, env)}) does not exist.`,
161
+ action: "Run `pithy secrets provision` first — the reconcile worker reads the rails' credentials from it.",
162
+ });
163
+ }
164
+ return { appDatabaseId, secretsDatabaseId: secretsDb.uuid };
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Build the live provisioner for a project, and resolve the project name its worker and Workflow names
170
+ * lead with. `requireProjectName` refuses to guess: the deployed script name has to be the same one the
171
+ * app's `script_name` binding points at, and a guess would bind a Worker that does not exist.
172
+ */
173
+ async function buildProvisioner(projectDir: string) {
174
+ // The name first, before the credentials: both are local checks, and a config that cannot name the
175
+ // project is not a Cloudflare problem to report as one.
176
+ const config = await loadProject(projectDir);
177
+ const project = requireProjectName(config);
178
+ // The project's own environment set, read once here and carried, so provisioning and `--env` agree.
179
+ const environments = loadProjectEnvironments(config);
180
+ const { account, accountId, apiToken, storeId } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
181
+ const paymentsConfig = await loadPaymentsConfig(projectDir);
182
+ const cf = new CloudflareClients({ accountId, apiToken });
183
+ return {
184
+ project,
185
+ environments,
186
+ paymentsConfig,
187
+ provisioner: new CloudflarePaymentsProvisioner({
188
+ cf,
189
+ project,
190
+ accountId,
191
+ apiToken,
192
+ storeId,
193
+ paymentsConfig,
194
+ resolveEnv: buildResolveEnv(projectDir, cf, project, account),
195
+ workflows: new CloudflareWorkflowsClient({ accountId, apiToken }),
196
+ audit: await buildAudit(projectDir, accountId, apiToken),
197
+ }),
198
+ };
199
+ }
200
+
201
+ const provision = defineCommand({
202
+ meta: { name: "provision", description: "Deploy the reconciliation Workflow worker and write its bindings" },
203
+ args: {
204
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
205
+ },
206
+ run: ({ args }) =>
207
+ withErrorReporting(args.json, async () => {
208
+ const projectDir = process.cwd();
209
+ const { provisioner, project, environments: declared } = await buildProvisioner(projectDir);
210
+ const { paymentsWorkflowRegistry, PAYMENTS_CAPABILITY } = await loadPayments();
211
+
212
+ // The account check first, before a single deploy. Failing here means failing before one environment is
213
+ // half provisioned rather than part way through the fan-out.
214
+ await provisioner.preflight();
215
+
216
+ const environments: ManagedEnvironment[] = managedEnvironments(declared);
217
+ for (const env of environments) {
218
+ await provisioner.deployWorker(env);
219
+ // Only now can the Workflow binding be written. `pithy add payments` cannot: wrangler requires a
220
+ // `name` and a `class_name` on every `workflows` entry, and the deployed name is per environment
221
+ // (`<project>-<env>-payments-reconcile`). An entry short of either field fails the whole config, so `add`
222
+ // emits none and this completes it — see capabilities/add.ts.
223
+ await applyAppBindings(projectDir, env, {
224
+ workflows: appWorkflowBindings(paymentsWorkflowRegistry, { project, capability: PAYMENTS_CAPABILITY, env }),
225
+ });
226
+ }
227
+
228
+ if (args.json) {
229
+ process.stdout.write(`${formatJsonLine({ command: "payments provision", environments })}\n`);
230
+ return;
231
+ }
232
+ for (const env of environments) {
233
+ process.stdout.write(`${env}: reconcile worker deployed, PAYMENTS_RECONCILE bound.\n`);
234
+ }
235
+ process.stdout.write(
236
+ "Set each rail's credentials with `pithy secrets set payments-provider-credentials` — nothing can mint them.\n",
237
+ );
238
+ process.stdout.write(`${formatDone()}\n`);
239
+ }),
240
+ });
241
+
242
+ const reconcile = defineCommand({
243
+ meta: { name: "reconcile", description: "Run a reconciliation pass now and report the drift it found" },
244
+ args: {
245
+ env: { ...envArg("Target environment"), default: "staging" },
246
+ // One flag, not a `--subject-type`/`--subject-id` pair. A holder is `(kind, id)` and half of one names
247
+ // nobody, so two flags would need a cross-arg rule to say what a single flag says by existing. The
248
+ // spelling is `encodeSubjectReference`'s — the same string the rails stamp into a store — so an operator
249
+ // reading a provider dashboard can paste what they see.
250
+ subject: {
251
+ type: "string",
252
+ description: "Reconcile one holder only, as `user:<id>` or `organization:<id>` — the support path",
253
+ },
254
+ // Every rail is named, in the spelling the parse accepts — `lemonSqueezy`, camelCase, the same
255
+ // identifier the config and the credential bundle key on. A help line that lists three of four rails
256
+ // is why somebody types the fourth as a guess.
257
+ rail: { type: "string", description: "Reconcile one rail only: apple, google, stripe, lemonSqueezy, or paddle" },
258
+ "dry-run": { type: "boolean", default: false, description: "Report the drift and write nothing" },
259
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
260
+ },
261
+ run: ({ args }) =>
262
+ withErrorReporting(args.json, async () => {
263
+ // Checked, not cast. `--env dev` is a real thing to type and dev is local-only, so the cast turned a
264
+ // one-line answer into a lookup for `<project>-dev-payments-reconcile` and a raw Cloudflare request
265
+ // error from a worker that was never deployed. Still checked first, before any Cloudflare client is
266
+ // built: the declaration it is checked against is a config read, so the refusal costs nothing.
267
+ const projectDir = process.cwd();
268
+ const config = await loadProject(projectDir);
269
+ // The name before the flag, because both payments names lead with it and a guessed one dispatches
270
+ // to a script that does not exist — the refusal that helps most goes first. Both are reads of this
271
+ // project's own config, so the whole check still happens before any Cloudflare client is built.
272
+ requireProjectName(config);
273
+ const env = requireManagedEnvironment(args.env, loadProjectEnvironments(config));
274
+ const { provisioner } = await buildProvisioner(projectDir);
275
+ const { PaymentsReconcileParams, decodeSubjectReference } = await loadPayments();
276
+
277
+ // Decoded through payments' own strict decoder, never split here. `--subject ada` is the shape
278
+ // somebody types from memory, and a lenient read of it would narrow the pass to whichever user *or*
279
+ // organization carries that id — a support tool answering about the wrong holder, silently. The
280
+ // refusal names the format instead.
281
+ const subject = args.subject === undefined ? undefined : decodeSubjectReference(args.subject);
282
+ if (args.subject !== undefined && subject === undefined) {
283
+ throw new ValidationError({
284
+ message: `"${args.subject}" does not name a holder.`,
285
+ action: "Pass --subject user:<id> or --subject organization:<id>.",
286
+ });
287
+ }
288
+
289
+ // Parsed here rather than sent raw: a mistyped rail is a message in this terminal instead of a Workflow
290
+ // instance that starts, fails a step, and burns its retry budget where nobody is watching.
291
+ //
292
+ // **Mapped, not thrown raw.** "A message in this terminal" means the house two-line refusal, and a
293
+ // bare `ZodError` is a stack trace — `--rail lemon-squeezy` is exactly the typo that used to earn
294
+ // one. The rails are not listed again here: Zod's own message names the accepted set, so the list
295
+ // stays in one place and gains the next rail on the day the schema does.
296
+ const parsed = PaymentsReconcileParams.safeParse({
297
+ ...(subject ?? {}),
298
+ ...(args.rail === undefined ? {} : { rail: args.rail }),
299
+ ...(args["dry-run"] ? { dryRun: true } : {}),
300
+ });
301
+ if (!parsed.success) {
302
+ throw fromZodError(parsed.error, {
303
+ message: parsed.error.issues.map((issue) => issue.message).join(" "),
304
+ action: "Spell --rail the way pithy.config.ts spells it, or drop it to reconcile every rail.",
305
+ });
306
+ }
307
+ const params = parsed.data;
308
+
309
+ const report = (await provisioner.reconcile(env, params)) as {
310
+ scanned?: number;
311
+ drifted?: number;
312
+ unchanged?: number;
313
+ skipped?: number;
314
+ failed?: number;
315
+ } | null;
316
+
317
+ if (args.json) {
318
+ process.stdout.write(`${formatJsonLine({ command: "payments reconcile", env, report })}\n`);
319
+ return;
320
+ }
321
+ process.stdout.write(
322
+ `${report?.scanned ?? 0} scanned, ${report?.drifted ?? 0} drifted, ${report?.skipped ?? 0} skipped, ${report?.failed ?? 0} failed.\n`,
323
+ );
324
+ // A rising drift count is the signal the webhook path is broken, so it is worth one plain sentence here
325
+ // rather than only a number.
326
+ if ((report?.drifted ?? 0) > 0 && !args["dry-run"]) {
327
+ process.stdout.write("Drift was repaired. Repeated drift means webhooks are not arriving — check the rail.\n");
328
+ }
329
+ process.stdout.write(`${formatDone()}\n`);
330
+ }),
331
+ });
332
+
333
+ export default defineCommand({
334
+ meta: { name: "payments", description: "Provision the reconciliation Workflow, and run a pass on demand" },
335
+ subCommands: { provision, reconcile },
336
+ });