@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,389 @@
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 { TokenPermission } from "@pithy-sh/cloudflare/src/tokens/accountTokensManager";
9
+ import type { PermissionKey } from "@pithy-sh/cloudflare/src/tokens/permissions";
10
+ import { permissionsForKeys } from "@pithy-sh/cloudflare/src/tokens/profiles";
11
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
12
+ import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
13
+ import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
14
+ import { secretsTokenProfile } from "@pithy-sh/secrets/src/capability";
15
+ import { encodeVersionedValue, initialVersionedValue } from "@pithy-sh/secrets/src/crypto/versionedValue";
16
+ import { secrets_0001_init } from "@pithy-sh/secrets/src/migrations/0001_init";
17
+ import {
18
+ initialMasterKeyConfig,
19
+ managerCfApiTokenName,
20
+ managerCfApiTokenSecretName,
21
+ masterKeySecretName,
22
+ type SecretsDeprovisioner,
23
+ type SecretsProvisioner,
24
+ } from "@pithy-sh/secrets/src/provision/provisionSecrets";
25
+ import {
26
+ type ManagerWranglerTemplate,
27
+ managerWorkerName,
28
+ resolveManagerConfig,
29
+ } from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
30
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
31
+ import { parse } from "comment-json";
32
+ import type { MigrationProvider } from "kysely/migration";
33
+ import type { CliAuditEmit } from "../audit/cliAudit";
34
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
35
+ import { runWrangler } from "../project/wrangler";
36
+
37
+ /** The secrets migration set, as provisioning runs it against each environment's D1. */
38
+ function secretsMigrationProvider(): MigrationProvider {
39
+ const registry = createMigrationRegistry([
40
+ { database: "secrets", namespace: "secrets", order: 100, migrations: { "0001_init": secrets_0001_init } },
41
+ ]);
42
+ const provider = registry.secrets;
43
+ if (!provider) throw new Error("missing secrets migration provider");
44
+ return provider;
45
+ }
46
+
47
+ /** The injected deploy step — resolve the manager's wrangler config and shell out to wrangler. */
48
+ export type DeployManager = (
49
+ env: ManagedEnvironment,
50
+ resolved: { databaseId: string; storeId: string },
51
+ ) => Promise<void>;
52
+
53
+ export interface CloudflareSecretsProvisionerOptions {
54
+ cf: CloudflareClients;
55
+ /** The CF account id, used to scope the minted manager token to this account's resources. */
56
+ /**
57
+ * The account this provisions into, and what vouches for it (#378).
58
+ *
59
+ * Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
60
+ * already held while a find-or-create read an empty listing as "this account has none" and minted a
61
+ * real resource in whichever account the shell had named. The id is still here — `account.accountId` —
62
+ * and it now travels with the answer to "who says so".
63
+ */
64
+ account: ConfirmedAccount;
65
+ /**
66
+ * The project name (root `pithy.config.ts` `name`, via `requireProjectName`). **Every** name this
67
+ * provisioner creates leads with it: the manager Worker, its D1, both of its Workflows, each Secrets
68
+ * Store entry, and the minted CF API token. All five namespaces are flat and account-wide, so this
69
+ * segment is the only thing stopping a second project from provisioning over this one — and a Worker
70
+ * deploy does not collide, it overwrites.
71
+ */
72
+ project: string;
73
+ /** The CF Secrets Store id holding the per-env master keys and the manager token. */
74
+ storeId: string;
75
+ /** Deploys the manager worker. Injected so the control-plane steps are testable without wrangler. */
76
+ deploy: DeployManager;
77
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
78
+ audit?: CliAuditEmit;
79
+ }
80
+
81
+ /**
82
+ * The least-privilege permissions the manager's minted token carries: Secrets Store Read + Write,
83
+ * scoped to this account. Derived from the predefined `secrets` token profile — the one source of the
84
+ * standard defaults each package needs (`pithy token`) — so the manager's scope and the profile never
85
+ * drift. The manager's only live-CF use is the rotation config write-back; its D1 work runs through
86
+ * the `SECRETS` binding, not this token — so nothing wider is granted.
87
+ */
88
+ export function managerTokenPermissions(accountId: string): TokenPermission[] {
89
+ return permissionsForKeys([...secretsTokenProfile.permissions] as PermissionKey[], accountId);
90
+ }
91
+
92
+ /**
93
+ * The live {@link SecretsProvisioner} — the CF + wrangler implementation of `pithy add secrets`.
94
+ * The control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: CF API only via that client)
95
+ * and are each idempotent; the manager deploy is the injected wrangler step. Every step here is
96
+ * exercised against live Cloudflare by the integration suite.
97
+ */
98
+ export class CloudflareSecretsProvisioner implements SecretsProvisioner {
99
+ readonly #cf: CloudflareClients;
100
+ readonly #account: ConfirmedAccount;
101
+ readonly #project: string;
102
+ readonly #storeId: string;
103
+ readonly #deploy: DeployManager;
104
+ readonly #audit: CliAuditEmit;
105
+
106
+ constructor(options: CloudflareSecretsProvisionerOptions) {
107
+ this.#cf = options.cf;
108
+ this.#account = options.account;
109
+ this.#project = options.project;
110
+ this.#storeId = options.storeId;
111
+ this.#deploy = options.deploy;
112
+ this.#audit = options.audit ?? (async () => {});
113
+ }
114
+
115
+ /** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy the managers. */
116
+ async preflight(): Promise<void> {
117
+ if (!(await this.#cf.workers().accountSubdomain())) {
118
+ throw new ValidationError({
119
+ message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
120
+ action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
121
+ });
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Ensure the manager's runtime CF API token. Reuse the value already in the Secrets Store if present
127
+ * (Cloudflare never returns a token's secret twice, so a stored token is trusted as-is); otherwise
128
+ * get a fresh secret via `rollToken` — roll the existing manager token's value in place if one exists,
129
+ * else mint a new least-privilege token — and write it into the store. A bootstrap token that cannot
130
+ * mint fails here, before any resource is created, with an actionable error.
131
+ */
132
+ async ensureManagerToken(): Promise<void> {
133
+ const entry = managerCfApiTokenSecretName(this.#project);
134
+ const store = this.#cf.secrets(this.#storeId);
135
+ if (await store.exists(entry)) return;
136
+ const minted = await this.#cf
137
+ .accountTokens()
138
+ .rollToken(managerCfApiTokenName(this.#project), managerTokenPermissions(this.#account.accountId));
139
+ await writeManagerCfApiToken(this.#cf, { storeId: this.#storeId, project: this.#project }, minted.value);
140
+ // Never the minted value — just that the manager's own runtime credential was (re)written.
141
+ await this.#audit({
142
+ environment: "global",
143
+ action: "secrets/set",
144
+ outcome: "success",
145
+ severity: "warning",
146
+ resourceType: "secret",
147
+ resourceId: entry,
148
+ metadata: { name: entry, kind: "manager_token" },
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Reuse the env's secrets D1 if it exists, otherwise create it.
154
+ *
155
+ * "Exists" means *this project's* database: the name is `<project>-<env>-secrets`. Unscoped, the
156
+ * second project in an account would find the first's database by name and adopt it — two projects
157
+ * sharing one secrets store, each able to read and overwrite the other's rows.
158
+ *
159
+ * And "exists" also means *an account this project claims* (#378). An empty listing from an account
160
+ * nothing vouches for is not the absence this reads it as, and creating on it stands a live secrets
161
+ * database up in somebody else's account.
162
+ */
163
+ async ensureDatabase(env: ManagedEnvironment): Promise<{ databaseId: string }> {
164
+ const name = managerWorkerName(this.#project, env);
165
+ const existing = await findOnConfirmedAccount({
166
+ ...this.#account,
167
+ what: `the ${name} database`,
168
+ find: () => this.#cf.d1Provisioner().findDatabaseByName(name),
169
+ });
170
+ const db = existing ?? (await this.#cf.d1Provisioner().createDatabase(name));
171
+ return { databaseId: db.uuid };
172
+ }
173
+
174
+ /**
175
+ * Mint the env's master key only if absent — replacing it would orphan every stored secret.
176
+ *
177
+ * The entry name is project-scoped, and that is what makes "absent" mean *this project's* key is
178
+ * absent. Under the old flat name, a second project provisioning into the same account would find
179
+ * the first project's key already there, skip the mint, and encrypt its own rows under a key it does
180
+ * not own — silently coupling two projects until one of them tears down and orphans both.
181
+ */
182
+ async ensureMasterKey(env: ManagedEnvironment): Promise<{ storeId: string }> {
183
+ const name = masterKeySecretName(this.#project, env);
184
+ const store = this.#cf.secrets(this.#storeId);
185
+ if (!(await store.exists(name))) {
186
+ await store.putSecret(name, JSON.stringify(await initialMasterKeyConfig()));
187
+ await this.#audit({
188
+ environment: env,
189
+ action: "secrets/set",
190
+ outcome: "success",
191
+ severity: "warning",
192
+ resourceType: "secret",
193
+ resourceId: name,
194
+ metadata: { name, kind: "master_key" },
195
+ });
196
+ }
197
+ return { storeId: this.#storeId };
198
+ }
199
+
200
+ /** Run the secrets migrations against the env's D1 over REST (idempotent — applied ones are skipped). */
201
+ async migrate(_env: ManagedEnvironment, databaseId: string): Promise<void> {
202
+ await runMigrations(this.#cf.d1(databaseId), secretsMigrationProvider());
203
+ }
204
+
205
+ /** Deploy the prebuilt manager worker for the environment. */
206
+ async deployManager(env: ManagedEnvironment, resolved: { databaseId: string; storeId: string }): Promise<void> {
207
+ await this.#deploy(env, resolved);
208
+ }
209
+ }
210
+
211
+ /** The directory of the prebuilt manager worker inside the installed `@pithy-sh/secrets` package. */
212
+ function managerDir(): string {
213
+ // Resolve through the package so it works installed (node_modules) or in the workspace; the
214
+ // `./src/*` export maps `worker` → `src/manager/worker.ts`, whose directory holds wrangler.jsonc.
215
+ return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/secrets/src/manager/worker")));
216
+ }
217
+
218
+ /**
219
+ * Write the scoped CF API token into the Secrets Store as the entry the manager binds at runtime.
220
+ * The value is the uniform versioned-value envelope (a one-entry envelope on first write), so the
221
+ * manager's `secretsStore` read decodes it exactly like every other secret. The token is `global` —
222
+ * one entry per project, written once and bound the same way by every one of that project's managers —
223
+ * so this is idempotent and re-runnable: `putSecret` upserts, and a re-deploy rewrites the same entry.
224
+ */
225
+ export async function writeManagerCfApiToken(
226
+ cf: CloudflareClients,
227
+ target: { storeId: string; project: string },
228
+ apiToken: string,
229
+ ): Promise<void> {
230
+ await cf
231
+ .secrets(target.storeId)
232
+ .putSecret(managerCfApiTokenSecretName(target.project), encodeVersionedValue(initialVersionedValue(apiToken)));
233
+ }
234
+
235
+ /**
236
+ * Build the live deploy step. It resolves the manager's `wrangler.jsonc` template into a per-env
237
+ * standalone config (filling the placeholder ids), writes it beside the worker so wrangler's relative
238
+ * `main` resolves, then runs `wrangler deploy --config <resolved>`. The temp config is removed after.
239
+ *
240
+ * **Two distinct tokens, by design.** `apiToken` is the broad bootstrap token (`.dev.vars`
241
+ * `CLOUDFLARE_API_TOKEN`) that authenticates the deploy itself — it can create Workers, D1, and so on.
242
+ * The manager's **least-privilege** runtime token (scoped to Secrets Store Read + Write) is minted and
243
+ * written into the Secrets Store earlier, by `ensureManagerToken`, so the worker's `CLOUDFLARE_API_TOKEN`
244
+ * binding already resolves by deploy time. The broad token never reaches the worker; the minted token
245
+ * never deploys. Auth flows through env vars, not `wrangler login` (CLAUDE.md §CF token bootstrap).
246
+ */
247
+ export function buildManagerDeploy(options: { accountId: string; apiToken: string; project: string }): DeployManager {
248
+ const { accountId, apiToken, project } = options;
249
+ return async (env, resolved) => {
250
+ const dir = managerDir();
251
+ const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as ManagerWranglerTemplate;
252
+ const config = resolveManagerConfig(template, { env, accountId, project, ...resolved });
253
+ const configPath = join(dir, `.wrangler.${env}.json`);
254
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
255
+ try {
256
+ await runWrangler(["deploy", "--config", configPath], {
257
+ cwd: dir,
258
+ env: { CLOUDFLARE_API_TOKEN: apiToken, CLOUDFLARE_ACCOUNT_ID: accountId },
259
+ });
260
+ } finally {
261
+ await unlink(configPath).catch(() => {});
262
+ }
263
+ };
264
+ }
265
+
266
+ export interface CloudflareSecretsDeprovisionerOptions {
267
+ cf: CloudflareClients;
268
+ /**
269
+ * The project name (root `pithy.config.ts` `name`, via `requireProjectName`). Teardown recomputes
270
+ * every name it deletes, so this must be the same value provisioning used — a guessed one would
271
+ * either match nothing (a silent leak) or, worse, match another project's entries.
272
+ */
273
+ project: string;
274
+ /** The CF Secrets Store id holding the per-env master keys. */
275
+ storeId: string;
276
+ /**
277
+ * The account this teardown deletes from, and what vouches for it (#378).
278
+ *
279
+ * Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
280
+ * miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
281
+ * exits 0. A caller that has not decided which account it is tearing down cannot compile.
282
+ */
283
+ account: ConfirmedAccount;
284
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
285
+ audit?: CliAuditEmit;
286
+ }
287
+
288
+ /**
289
+ * The live {@link SecretsDeprovisioner} — removes each environment's manager worker, (optionally) its
290
+ * master key, and its secrets D1, all through `@pithy-sh/cloudflare`. Every step is guarded so a
291
+ * missing resource is a no-op: teardown is idempotent and safe to re-run. The integration suite
292
+ * exercises the full provision → teardown round trip.
293
+ */
294
+ export class CloudflareSecretsDeprovisioner implements SecretsDeprovisioner {
295
+ readonly #cf: CloudflareClients;
296
+ readonly #project: string;
297
+ readonly #storeId: string;
298
+ readonly #account: ConfirmedAccount;
299
+ readonly #audit: CliAuditEmit;
300
+
301
+ constructor(options: CloudflareSecretsDeprovisionerOptions) {
302
+ this.#cf = options.cf;
303
+ this.#project = options.project;
304
+ this.#storeId = options.storeId;
305
+ this.#account = options.account;
306
+ this.#audit = options.audit ?? (async () => {});
307
+ }
308
+
309
+ /**
310
+ * Delete the env's manager worker if it is deployed. Guarded, so teardown is idempotent — which is
311
+ * also why `project` must be the value provisioning used: a mismatch finds nothing, deletes nothing,
312
+ * and exits 0 while the real manager keeps running.
313
+ *
314
+ * A wrong *account* has the same three consequences and had no guard at all, so the lookup goes
315
+ * through `findOnConfirmedAccount` (#378): an empty listing is only an absence once something says
316
+ * whose account answered.
317
+ */
318
+ async deleteManager(env: ManagedEnvironment): Promise<void> {
319
+ const name = managerWorkerName(this.#project, env);
320
+ if (
321
+ await findOnConfirmedAccount({
322
+ ...this.#account,
323
+ what: `the ${name} Worker`,
324
+ find: () => this.#cf.workers().getWorker(name),
325
+ })
326
+ ) {
327
+ await this.#cf.workers().deleteWorker(name);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Delete the env's master key if it is present — destructive, called only on a full destroy. The
333
+ * name is project-scoped, so this can only ever reach this project's key: another project's key in
334
+ * the same account-wide store is a different entry and is left readable.
335
+ */
336
+ async deleteMasterKey(env: ManagedEnvironment): Promise<void> {
337
+ const name = masterKeySecretName(this.#project, env);
338
+ const store = this.#cf.secrets(this.#storeId);
339
+ if (await store.exists(name)) {
340
+ await store.deleteSecret(name);
341
+ // Deleting a master key orphans every secret it encrypted — this is the destructive step.
342
+ await this.#audit({
343
+ environment: env,
344
+ action: "secrets/removed",
345
+ outcome: "success",
346
+ severity: "warning",
347
+ resourceType: "secret",
348
+ resourceId: name,
349
+ metadata: { name, kind: "master_key" },
350
+ });
351
+ }
352
+ }
353
+
354
+ /** Delete the env's secrets D1 if it exists. Project-scoped by name, like the manager above. */
355
+ async deleteDatabase(env: ManagedEnvironment): Promise<void> {
356
+ const db = await this.#cf.d1Provisioner().findDatabaseByName(managerWorkerName(this.#project, env));
357
+ if (db) {
358
+ await this.#cf.d1Provisioner().deleteDatabase(db.uuid);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Remove this project's manager token entirely — the inverse of `ensureManagerToken`. Delete the
364
+ * minted account token from Cloudflare (every same-named token, so a re-minted duplicate is swept
365
+ * too), then its Secrets Store entry. Both guarded: a missing token or entry is a no-op, so teardown
366
+ * is idempotent.
367
+ *
368
+ * `deleteTokensByName` is a name sweep over the whole account, so the project scope on the name is
369
+ * the containment: unscoped, one project's `pithy secrets deprovision` would revoke every other
370
+ * project's manager credential in the account and break all of their rotations at once.
371
+ */
372
+ async deleteManagerToken(): Promise<void> {
373
+ await this.#cf.accountTokens().deleteTokensByName(managerCfApiTokenName(this.#project));
374
+ const entry = managerCfApiTokenSecretName(this.#project);
375
+ const store = this.#cf.secrets(this.#storeId);
376
+ if (await store.exists(entry)) {
377
+ await store.deleteSecret(entry);
378
+ await this.#audit({
379
+ environment: "global",
380
+ action: "secrets/removed",
381
+ outcome: "success",
382
+ severity: "warning",
383
+ resourceType: "secret",
384
+ resourceId: entry,
385
+ metadata: { name: entry, kind: "manager_token" },
386
+ });
387
+ }
388
+ }
389
+ }