@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,236 @@
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 { ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
10
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
11
+ import { parse } from "comment-json";
12
+ import type { CliAuditEmit } from "../audit/cliAudit";
13
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
14
+ import { runWrangler } from "../project/wrangler";
15
+ import { capabilityLoadError } from "./loadFailure";
16
+
17
+ /**
18
+ * The live testers provisioner — the Cloudflare + wrangler implementation behind `@pithy-sh/testers`'s
19
+ * `TestersProvisioner` seam. The account check goes through `@pithy-sh/cloudflare` (CLAUDE.md: the CF
20
+ * API only via that client); the worker deploy shells out to wrangler with the bootstrap token.
21
+ *
22
+ * `@pithy-sh/testers` is an **optional** capability, so the CLI must not hard-depend on it. Types come
23
+ * in through type-only imports (erased at build), and every runtime value comes through
24
+ * {@link loadTestersProvisioning} — a guarded dynamic import that turns "the package isn't installed"
25
+ * into an actionable error rather than an unresolved-module crash.
26
+ */
27
+
28
+ /** The testers runtime surface provisioning needs, loaded from the project's own install. */
29
+ type TestersProvisionModule = typeof import("@pithy-sh/testers/src/provision/provisionTesters");
30
+ type TestersResolveModule = typeof import("@pithy-sh/testers/src/provision/resolveTestersConfig");
31
+ type TestersSpecsModule = typeof import("@pithy-sh/testers/src/workflows/specs");
32
+
33
+ /** The provisioner seams, referenced by type only so the CLI gains no dependency on the package. */
34
+ type TestersProvisioner = import("@pithy-sh/testers/src/provision/provisionTesters").TestersProvisioner;
35
+ type TestersDeprovisioner = import("@pithy-sh/testers/src/provision/provisionTesters").TestersDeprovisioner;
36
+ type TestersConfig = import("@pithy-sh/testers/src/config/config").TestersConfig;
37
+ type TestersEmailIdentity = import("@pithy-sh/testers/src/provision/resolveTestersConfig").TestersEmailIdentity;
38
+
39
+ /** Everything `pithy testers provision` loads out of the optional package, in one guarded import. */
40
+ export type TestersProvisionSurface = TestersProvisionModule & TestersResolveModule & TestersSpecsModule;
41
+
42
+ /**
43
+ * Load the provisioning surface from the project's own install.
44
+ *
45
+ * Separate from `testersLoader.ts`'s `loadTesters`, which pulls the data-layer modules the roster
46
+ * commands need. Splitting them keeps `pithy testers status` from importing a wrangler template
47
+ * resolver it will never call.
48
+ */
49
+ export async function loadTestersProvisioning(): Promise<TestersProvisionSurface> {
50
+ try {
51
+ const [provision, resolve, specs] = await Promise.all([
52
+ import("@pithy-sh/testers/src/provision/provisionTesters"),
53
+ import("@pithy-sh/testers/src/provision/resolveTestersConfig"),
54
+ import("@pithy-sh/testers/src/workflows/specs"),
55
+ ]);
56
+ return { ...provision, ...resolve, ...specs };
57
+ } catch (error) {
58
+ throw capabilityLoadError("testers", "@pithy-sh/testers", error);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The directory of the prebuilt daily-pass worker inside the installed package.
64
+ *
65
+ * Resolved from the module rather than assembled from a path, so it keeps working however the adopter's
66
+ * package manager laid `node_modules` out.
67
+ */
68
+ async function testersWorkerDir(): Promise<string> {
69
+ try {
70
+ return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/testers/src/workflows/worker")));
71
+ } catch (error) {
72
+ throw capabilityLoadError("testers", "@pithy-sh/testers/src/workflows/worker", error);
73
+ }
74
+ }
75
+
76
+ /** The per-environment resource ids the daily-pass worker binds, resolved by the caller. */
77
+ export interface TestersEnvResources {
78
+ /** The app database id — the `pithy_testers_*`, `pithy_auth_*` and `pithy_email_jobs` tables. */
79
+ readonly appDatabaseId: string;
80
+ /** The global email-suppression database id, shared across environments. */
81
+ readonly suppressionDatabaseId: string;
82
+ }
83
+
84
+ /** Resolve the per-environment resources for the host (from the project wrangler + name lookups). */
85
+ export type ResolveTestersEnv = (env: ManagedEnvironment) => Promise<TestersEnvResources>;
86
+
87
+ export interface CloudflareTestersProvisionerOptions {
88
+ readonly cf: CloudflareClients;
89
+ /**
90
+ * The account this provisions into, and what vouches for it (#378).
91
+ *
92
+ * Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
93
+ * already held while a find-or-create read an empty listing as "this account has none" and minted a
94
+ * real resource in whichever account the shell had named. The id is still here — `account.accountId` —
95
+ * and it now travels with the answer to "who says so".
96
+ */
97
+ account: ConfirmedAccount;
98
+ /**
99
+ * The project name, from `requireProjectName(await loadProject(projectDir))` — never
100
+ * `resolveProjectName`. The deployed host, its daily Workflow, and the suppression database name all
101
+ * lead with it, and a guessed value deploys a host teardown will never find.
102
+ */
103
+ readonly project: string;
104
+ /** The broad bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
105
+ readonly apiToken: string;
106
+ /** The app's resolved testers config — serialized into the host's `TESTERS_CONFIG` var. */
107
+ readonly testersConfig: TestersConfig;
108
+ /** The sending identity, or undefined when the project composes no email capability. */
109
+ readonly email: TestersEmailIdentity | undefined;
110
+ /** Resolve the per-env database ids — injected so it is testable and decoupled from wrangler parsing. */
111
+ readonly resolveEnv: ResolveTestersEnv;
112
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
113
+ readonly audit?: CliAuditEmit;
114
+ }
115
+
116
+ /** The live provisioner. Every step is idempotent, so provisioning is safe to re-run. */
117
+ export class CloudflareTestersProvisioner implements TestersProvisioner, TestersDeprovisioner {
118
+ readonly #cf: CloudflareClients;
119
+ readonly #account: ConfirmedAccount;
120
+ readonly #project: string;
121
+ readonly #apiToken: string;
122
+ readonly #testersConfig: TestersConfig;
123
+ readonly #email: TestersEmailIdentity | undefined;
124
+ readonly #resolveEnv: ResolveTestersEnv;
125
+ readonly #audit: CliAuditEmit;
126
+
127
+ constructor(options: CloudflareTestersProvisionerOptions) {
128
+ this.#cf = options.cf;
129
+ this.#account = options.account;
130
+ this.#project = options.project;
131
+ this.#apiToken = options.apiToken;
132
+ this.#testersConfig = options.testersConfig;
133
+ this.#email = options.email;
134
+ this.#resolveEnv = options.resolveEnv;
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 + ids, then `wrangler deploy`. */
149
+ async deployWorker(env: ManagedEnvironment): Promise<void> {
150
+ const { resolveTestersConfig, testersWorkerName } = await loadTestersProvisioning();
151
+ const { appDatabaseId, suppressionDatabaseId } = await this.#resolveEnv(env);
152
+ const dir = await testersWorkerDir();
153
+ const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
154
+
155
+ const config = resolveTestersConfig(template, {
156
+ project: this.#project,
157
+ env,
158
+ appDatabaseId,
159
+ suppressionDatabaseId,
160
+ testersConfig: this.#testersConfig,
161
+ email: this.#email,
162
+ });
163
+
164
+ // Written beside the template and removed afterwards. Leaving a resolved config in the package
165
+ // directory would leave one environment's database ids sitting in `node_modules` after the command
166
+ // that needed them finished.
167
+ const configPath = join(dir, `.wrangler.${env}.json`);
168
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
169
+ try {
170
+ await runWrangler(["deploy", "--config", configPath], {
171
+ cwd: dir,
172
+ env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#account.accountId },
173
+ });
174
+ await this.#audit({
175
+ environment: env,
176
+ action: "testers/worker_deployed",
177
+ outcome: "success",
178
+ severity: "info",
179
+ resourceType: "cf_worker",
180
+ resourceId: testersWorkerName(this.#project, env),
181
+ metadata: { sends: this.#email !== undefined },
182
+ });
183
+ } catch (error) {
184
+ await this.#audit({
185
+ environment: env,
186
+ action: "testers/worker_deployed",
187
+ outcome: "failure",
188
+ severity: "warning",
189
+ resourceType: "cf_worker",
190
+ resourceId: testersWorkerName(this.#project, env),
191
+ });
192
+ throw error;
193
+ } finally {
194
+ await unlink(configPath).catch(() => {});
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Delete this environment's host, if it is deployed.
200
+ *
201
+ * The existence check is what makes teardown idempotent, and it is not belt-and-braces: Cloudflare
202
+ * answers a delete for an absent script with a 404, `cloudflareRequest` rethrows that as a
203
+ * `CloudflareRequestError`, and `deprovisionTesters` has no per-environment catch — so an
204
+ * un-provisioned staging would abort the fan-out before production was attempted. Every sibling
205
+ * deprovisioner in this directory guards the same call for the same reason.
206
+ *
207
+ * The audit sits inside the guard too. A `worker_deleted` event for a worker that never existed is a
208
+ * false entry in the one log that is supposed to be the record of what actually happened.
209
+ *
210
+ * **And the guard now refuses what it cannot settle (#378).** "This account has no such worker" and "I
211
+ * asked an account nothing claims" arrived here as the same `null`, so a teardown pointed at the wrong
212
+ * account deleted nothing, audited nothing, and exited 0 — a success message over an untouched
213
+ * production Worker. The lookup does not even happen unless something vouches for the account.
214
+ */
215
+ async deleteWorker(env: ManagedEnvironment): Promise<void> {
216
+ const { testersWorkerName } = await loadTestersProvisioning();
217
+ const name = testersWorkerName(this.#project, env);
218
+ if (
219
+ await findOnConfirmedAccount({
220
+ ...this.#account,
221
+ what: `the ${name} Worker`,
222
+ find: () => this.#cf.workers().getWorker(name),
223
+ })
224
+ ) {
225
+ await this.#cf.workers().deleteWorker(name);
226
+ await this.#audit({
227
+ environment: env,
228
+ action: "testers/worker_deleted",
229
+ outcome: "success",
230
+ severity: "warning",
231
+ resourceType: "cf_worker",
232
+ resourceId: name,
233
+ });
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,347 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
7
+ import { dispatchSecretWrite, type SecretDispatcher } from "@pithy-sh/secrets/src/cli/dispatch";
8
+ import { initialDevSecret } from "@pithy-sh/secrets/src/dev/devSecretsFile";
9
+ import type { SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
10
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
11
+ import { TurnstileMode } from "@pithy-sh/turnstile/src/config/config";
12
+ import {
13
+ type ManagedTurnstileEnv,
14
+ productionWidgetName,
15
+ sitekeyVarName,
16
+ type TurnstileDeprovisioner,
17
+ type TurnstileProvisioner,
18
+ } from "@pithy-sh/turnstile/src/provision/provisionTurnstile";
19
+ import { TURNSTILE_SECRET_NAME, turnstileSecretsRegistry } from "@pithy-sh/turnstile/src/secret/registry";
20
+ import type { CliAuditEmit } from "../audit/cliAudit";
21
+ import { answerOnConfirmedAccount, type ConfirmedAccount, unconfirmedAccount } from "../cloudflare/accountAnswer";
22
+ import { removeBootstrapVars } from "../devSecrets/bootstrapVars";
23
+ import { writeDevVars } from "../devSecrets/devVars";
24
+ import { removeDevSecrets, writeDevSecrets } from "../devSecrets/file";
25
+ import { resolveDevSecretsFile } from "../devSecrets/location";
26
+ import { renderDevVarsNotes } from "../devSecrets/report";
27
+ import { readWranglerConfig, type WranglerEnvVars, writeWranglerConfig } from "../project/wrangler";
28
+
29
+ /** The message of an unknown thrown value, for surfacing both legs of a failed upsert. */
30
+ function errorMessage(error: unknown): string {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+
34
+ /** A Cloudflare *managed* (visible) or *invisible* widget — the CF API's terms for our two modes. */
35
+ function cloudflareMode(mode: TurnstileMode): "managed" | "invisible" {
36
+ return mode === "visible" ? "managed" : "invisible";
37
+ }
38
+
39
+ /**
40
+ * Every widget name this project can own — one per mode, whether or not config enables both.
41
+ *
42
+ * The domain guard subtracts this set rather than matching the one mode being provisioned: a project
43
+ * running a visible *and* an invisible widget has two of its own widgets on the domain, and the second
44
+ * pass must not read the first as a squatter. Deriving it from `TurnstileMode` keeps it whole even when
45
+ * config later enables a mode a previous run already created.
46
+ */
47
+ function ourWidgetNames(project: string): Set<string> {
48
+ return new Set(TurnstileMode.options.map((mode) => productionWidgetName(project, mode)));
49
+ }
50
+
51
+ /** The routing facts the turnstile secret carries — a `d1`, per-environment, rotatable JSON value. */
52
+ const SECRET_FACTS = { backend: "d1", scope: "environment", rotatable: true, valueType: "json" } as const;
53
+
54
+ export interface CloudflareTurnstileProvisionerOptions {
55
+ cf: CloudflareClients;
56
+ /**
57
+ * The account the production widget is created in, and what vouches for it (#378).
58
+ *
59
+ * `assertDomainAvailable` reads an empty listing as "the domain is free" and provisioning then mints a
60
+ * real widget. Against an account nothing claims, that listing is empty because the widgets it would
61
+ * have named are somewhere else — so the guard passes for the wrong reason and a live widget lands in a
62
+ * stranger's account.
63
+ */
64
+ account: ConfirmedAccount;
65
+ /**
66
+ * The project name from the root `pithy.config.ts`, resolved by `requireProjectName` and never
67
+ * guessed. It is the leading segment of every widget name, so a wrong value here reuses — and on
68
+ * teardown deletes — another project's widget (docs/NAMING.md).
69
+ */
70
+ project: string;
71
+ /**
72
+ * The project root — owner of the one shared `.dev.vars` every worker symlinks to, so a dev sitekey
73
+ * written here reaches every worker at once.
74
+ */
75
+ projectDir: string;
76
+ /**
77
+ * The web-facing Worker's directory — its `wrangler.jsonc` is where the per-environment sitekey vars are
78
+ * written. Per-Worker, because the widget is bound to the domain *that* Worker serves (`BASE_URL`).
79
+ */
80
+ workerDir: string;
81
+ /** The secrets manager dispatcher — writes/deletes the secret in a deployed env's managed store. */
82
+ dispatcher: SecretDispatcher;
83
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
84
+ audit?: CliAuditEmit;
85
+ /**
86
+ * Where a delivery note goes. Defaults to **stderr**, one line at a time.
87
+ *
88
+ * `writeDev` returns `void` — the interface in `@pithy-sh/turnstile` says so — and that is precisely
89
+ * how the report got dropped: there was nowhere to return it to, so it was discarded and the provision
90
+ * reported a delivery that had not happened. A seam, so a test can read the lines; stderr by default,
91
+ * because the alternative to a default is silence, and because `--json` writes its one line to stdout
92
+ * and a diagnostic must not land in the middle of it.
93
+ *
94
+ * Never a value. See `renderDevVarsNotes`.
95
+ */
96
+ notes?: (line: string) => void;
97
+ /** Every environment this project declares, from the root `pithy.config.ts` — the fan-out set for a `global` secret. */
98
+ environments: DeclaredEnvironments | readonly string[];
99
+ }
100
+
101
+ /**
102
+ * The live {@link TurnstileProvisioner}. The widget secret is written like any other secret — `.dev.vars`
103
+ * for dev, the per-environment manager Workflow for staging/prod (CLAUDE.md §secrets) — and the real
104
+ * production widget is created through `@pithy-sh/cloudflare`. dev/managed-sitekey writes are idempotent
105
+ * file upserts; the managed secret write upserts (create, else update); widget creation reuses by name.
106
+ */
107
+ export class CloudflareTurnstileProvisioner implements TurnstileProvisioner {
108
+ readonly #cf: CloudflareClients;
109
+ readonly #account: ConfirmedAccount;
110
+ readonly #project: string;
111
+ readonly #projectDir: string;
112
+ readonly #workerDir: string;
113
+ readonly #dispatcher: SecretDispatcher;
114
+ /**
115
+ * The project's declared environments (#241) — what a `global` secret write fans out across. Carried
116
+ * rather than assumed, so a shared secret reaches every environment the project deploys to.
117
+ */
118
+ readonly #environments: DeclaredEnvironments | readonly string[];
119
+ readonly #audit: CliAuditEmit;
120
+ readonly #notes: (line: string) => void;
121
+
122
+ constructor(options: CloudflareTurnstileProvisionerOptions) {
123
+ this.#cf = options.cf;
124
+ this.#account = options.account;
125
+ this.#project = options.project;
126
+ this.#projectDir = options.projectDir;
127
+ this.#workerDir = options.workerDir;
128
+ this.#dispatcher = options.dispatcher;
129
+ this.#environments = options.environments;
130
+ this.#audit = options.audit ?? (async () => {});
131
+ this.#notes = options.notes ?? ((line: string) => void process.stderr.write(`${line}\n`));
132
+ }
133
+
134
+ /**
135
+ * Refuse the domain if a widget that is not ours already covers it — and refuse the *question* if the
136
+ * account that would answer it is one nothing claims (#378).
137
+ *
138
+ * The two failures are opposite in shape and identical on the wire. A foreign widget is a listing with
139
+ * an entry in it; an unconfirmed account is a listing with nothing in it, which is the same thing "the
140
+ * domain is free" looks like. Only one of those two empties is a fact, and the other one ends with a
141
+ * live production widget in an account this project never named.
142
+ */
143
+ async assertDomainAvailable(domain: string): Promise<void> {
144
+ const ours = ourWidgetNames(this.#project);
145
+ const answer = await answerOnConfirmedAccount({
146
+ ...this.#account,
147
+ what: `Turnstile widgets covering ${domain}`,
148
+ find: () => this.#cf.turnstile().listTurnstilesByDomain(domain),
149
+ });
150
+ if (answer.state === "unconfirmed")
151
+ throw unconfirmedAccount(answer.accountId, `Turnstile widgets covering ${domain}`);
152
+ const claimants = answer.state === "found" ? answer.value : [];
153
+ const foreign = claimants.find((widget) => !ours.has(widget.name));
154
+ if (!foreign) return;
155
+ throw new ValidationError({
156
+ message: `A Turnstile widget named "${foreign.name}" already covers ${domain}.`,
157
+ action: "Bind this project to a different domain, delete that widget, or re-run with --allow-shared-domain.",
158
+ detail: `sitekey ${foreign.sitekey} claims ${domain}; this project's widgets are ${[...ours].join(", ")}`,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * The dev widget's secret and its public sitekeys, each into the file its namespace belongs to.
164
+ *
165
+ * **The secret is a `d1` registry secret, so it goes into the dev secrets file (#149)** — through
166
+ * `writeDevSecrets`, the one funnel every dev secret passes through, at `<config>/<project>/` since
167
+ * #156. Writing it straight into `.dev.vars` bypassed the format and the mode both, and made this the
168
+ * fifth producer of the same defect. `replace`, because Cloudflare issued this value: keeping an older
169
+ * one because a value is already there leaves the project verifying against a widget it no longer has.
170
+ *
171
+ * The sitekeys are public, `UPPER_SNAKE`, and wrangler's — they stay in `.dev.vars`, and they are now
172
+ * the only thing this writes there. The secret itself was copied alongside them until #153, because
173
+ * dev resolved every secret from its binding whatever its backend; dev reads the seeded row now, so
174
+ * the copy is gone and a public sitekey no longer shares a file with a widget secret.
175
+ *
176
+ * **And what that write says is said, not dropped.** This call took no result at all, so a provision
177
+ * announced a delivery that may never have happened: a Worker with a `.dev.vars` of its own gets no
178
+ * sitekey and no secret, and `pithy turnstile provision` still printed "Test secret wired for dev".
179
+ * The same defect fixed at `pithy add`'s two call sites, in the third one nobody checked — three
180
+ * producers again, so it goes through the one renderer they share.
181
+ */
182
+ async writeDev(secret: string, sitekeys: Record<string, string>): Promise<void> {
183
+ // Through the registry entry, like every other writer: the entry is what says whether this
184
+ // secret's destination takes an envelope or the value itself (#323).
185
+ const entry: SecretRegistryEntry | undefined = turnstileSecretsRegistry[TURNSTILE_SECRET_NAME];
186
+ const envelope = initialDevSecret(entry ?? {}, secret);
187
+ const path = await resolveDevSecretsFile(this.#projectDir);
188
+ await writeDevSecrets(path, { [TURNSTILE_SECRET_NAME]: envelope }, { replace: true });
189
+ // The sitekeys alone, through `writeDevVars` — so each is quoted for dotenv and reaches the Worker's
190
+ // own directory rather than the project root alone. The secret goes to the store, on the next seed.
191
+ const wrote = await writeDevVars({ projectDir: this.#projectDir, values: { ...sitekeys } });
192
+ for (const note of renderDevVarsNotes(wrote)) this.#notes(note);
193
+ }
194
+
195
+ async writeManagedSecret(env: ManagedTurnstileEnv, secret: string): Promise<void> {
196
+ // Upsert: create on first provision, update on a re-run (create rejects an existing secret) — so the
197
+ // write is idempotent. If create fails for a real reason, the update almost always fails too; surface
198
+ // BOTH causes (create as `cause`) so the true failure isn't masked by the fallback's error.
199
+ const write = { name: TURNSTILE_SECRET_NAME, ...SECRET_FACTS, value: secret, requested: env as ManagedEnvironment };
200
+ try {
201
+ await dispatchSecretWrite(this.#dispatcher, { mode: "create", ...write }, this.#environments);
202
+ } catch (createError) {
203
+ try {
204
+ await dispatchSecretWrite(this.#dispatcher, { mode: "update", ...write }, this.#environments);
205
+ } catch (updateError) {
206
+ throw new InternalError(
207
+ {
208
+ message: `Could not write the turnstile secret to ${env}.`,
209
+ detail: `create failed: ${errorMessage(createError)}; update failed: ${errorMessage(updateError)}`,
210
+ },
211
+ { cause: createError },
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ async writeManagedSitekeys(env: ManagedTurnstileEnv, sitekeys: Record<string, string>): Promise<void> {
218
+ await editEnvVars(this.#workerDir, env, (vars) => Object.assign(vars, sitekeys));
219
+ }
220
+
221
+ async ensureProductionWidget(
222
+ mode: TurnstileMode,
223
+ domain: string,
224
+ ): Promise<{ sitekey: string; secret: string | null }> {
225
+ const name = productionWidgetName(this.#project, mode);
226
+ const existing = await this.#cf.turnstile().getTurnstile(name);
227
+ if (existing) return { sitekey: existing.sitekey, secret: null };
228
+ const created = await this.#cf.turnstile().addTurnstile(name, [domain], cloudflareMode(mode));
229
+ await this.#audit({
230
+ action: "turnstile/widget_created",
231
+ outcome: "success",
232
+ severity: "info",
233
+ resourceType: "turnstile_widget",
234
+ resourceId: created.sitekey,
235
+ metadata: { name, mode, domain },
236
+ });
237
+ return { sitekey: created.sitekey, secret: created.secret };
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The live {@link TurnstileDeprovisioner} — deletes each production widget, the managed secret in every
243
+ * deployed environment, and every config entry (dev-vars + managed sitekey vars). Each step is guarded so
244
+ * a missing resource is a no-op: teardown is idempotent.
245
+ */
246
+ export class CloudflareTurnstileDeprovisioner implements TurnstileDeprovisioner {
247
+ readonly #cf: CloudflareClients;
248
+ readonly #project: string;
249
+ readonly #projectDir: string;
250
+ readonly #workerDir: string;
251
+ readonly #dispatcher: SecretDispatcher;
252
+ /**
253
+ * The project's declared environments (#241) — what a `global` secret write fans out across. Carried
254
+ * rather than assumed, so a shared secret reaches every environment the project deploys to.
255
+ */
256
+ readonly #environments: DeclaredEnvironments | readonly string[];
257
+ readonly #audit: CliAuditEmit;
258
+
259
+ constructor(options: CloudflareTurnstileProvisionerOptions) {
260
+ this.#cf = options.cf;
261
+ this.#project = options.project;
262
+ this.#projectDir = options.projectDir;
263
+ this.#workerDir = options.workerDir;
264
+ this.#dispatcher = options.dispatcher;
265
+ this.#environments = options.environments;
266
+ this.#audit = options.audit ?? (async () => {});
267
+ }
268
+
269
+ async deleteProductionWidget(mode: TurnstileMode): Promise<void> {
270
+ const name = productionWidgetName(this.#project, mode);
271
+ const existing = await this.#cf.turnstile().getTurnstile(name);
272
+ if (!existing) return;
273
+ await this.#cf.turnstile().deleteTurnstile(existing.sitekey);
274
+ await this.#audit({
275
+ action: "turnstile/widget_deleted",
276
+ outcome: "success",
277
+ severity: "warning",
278
+ resourceType: "turnstile_widget",
279
+ resourceId: existing.sitekey,
280
+ metadata: { name, mode },
281
+ });
282
+ }
283
+
284
+ async deleteManagedSecret(): Promise<void> {
285
+ // The project's declaration, not a hardcoded pair: a project that deploys to `live` had its secret
286
+ // written there and would have kept it forever, because teardown only ever looked at two names.
287
+ for (const env of this.#environments) {
288
+ // Delete is idempotent in the manager (a missing name is a no-op), so this is safe to re-run.
289
+ await dispatchSecretWrite(
290
+ this.#dispatcher,
291
+ {
292
+ mode: "delete",
293
+ name: TURNSTILE_SECRET_NAME,
294
+ ...SECRET_FACTS,
295
+ requested: env,
296
+ },
297
+ this.#environments,
298
+ );
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Both halves of what {@link CloudflareTurnstileProvisioner.writeDev} wrote — the secret in the dev
304
+ * secrets file, and the sitekeys in `.dev.vars`. Leaving the value in the secrets file would have the
305
+ * next `pithy dev` seed a key for a widget that no longer exists.
306
+ *
307
+ * The secret's name is still passed to the removal, and that is deliberate: a project provisioned
308
+ * before #153 recorded the transitional copy, and teardown is the run that should take it. A name that
309
+ * is not recorded is a no-op.
310
+ *
311
+ * **The adopter's own `.dev.vars` is not touched.** Each Worker's is generated from the bootstrap set,
312
+ * so taking the names out of that set is what drops the lines — and the project root's file, if there
313
+ * is one, is theirs. See #154.
314
+ */
315
+ async clearDev(modes: TurnstileMode[]): Promise<void> {
316
+ const keys = [TURNSTILE_SECRET_NAME, ...modes.map((mode) => sitekeyVarName(mode))];
317
+ await removeBootstrapVars(this.#projectDir, keys);
318
+ await writeDevVars({ projectDir: this.#projectDir, values: {} });
319
+ await removeDevSecrets(await resolveDevSecretsFile(this.#projectDir), [TURNSTILE_SECRET_NAME]);
320
+ }
321
+
322
+ async clearManagedSitekeys(modes: TurnstileMode[]): Promise<void> {
323
+ const keys = modes.map((mode) => sitekeyVarName(mode));
324
+ for (const env of ["staging", "prod"] as const) {
325
+ await editEnvVars(this.#workerDir, env, (vars) => {
326
+ for (const key of keys) delete vars[key];
327
+ });
328
+ }
329
+ }
330
+ }
331
+
332
+ /** Read the Worker's `wrangler.jsonc`, mutate its `env.<env>.vars` map, and write it back comment-preserving. */
333
+ async function editEnvVars(
334
+ workerDir: string,
335
+ env: ManagedTurnstileEnv,
336
+ mutate: (vars: Record<string, string>) => void,
337
+ ): Promise<void> {
338
+ const config = (await readWranglerConfig(workerDir)) as WranglerEnvVars;
339
+ config.env ??= {};
340
+ config.env[env] ??= {};
341
+ const stanza = config.env[env];
342
+ if (stanza) {
343
+ stanza.vars ??= {};
344
+ mutate(stanza.vars);
345
+ }
346
+ await writeWranglerConfig(workerDir, config);
347
+ }