@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,471 @@
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 { R2Credentials } from "@pithy-sh/cloudflare/src/r2/r2Credentials";
9
+ import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
10
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
11
+ import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
12
+ import { dispatchSecretWrite, type SecretDispatcher } from "@pithy-sh/secrets/src/cli/dispatch";
13
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
14
+ import { parse } from "comment-json";
15
+ import type { CliAuditEmit } from "../audit/cliAudit";
16
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
17
+ import { runWrangler } from "../project/wrangler";
18
+ import { capabilityLoadError } from "./loadFailure";
19
+ import { deleteR2BucketWithContents } from "./r2Bucket";
20
+
21
+ /**
22
+ * The live media provisioner — the Cloudflare + wrangler implementation behind `@pithy-sh/media`'s
23
+ * `MediaProvisioner` seam. Control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: the CF API
24
+ * only via that client) and are each idempotent; the worker deploy shells out to wrangler with the
25
+ * bootstrap token.
26
+ *
27
+ * `@pithy-sh/media` is an **optional** capability, so the CLI must not hard-depend on it. Types come in
28
+ * through type-only imports (erased at build), and every runtime value comes through {@link loadMedia} —
29
+ * a guarded dynamic import that turns "the package isn't installed" into an actionable error rather than
30
+ * an unresolved-module crash.
31
+ */
32
+
33
+ /** The media runtime surface provisioning needs, loaded from the project's own install. */
34
+ type MediaProvisionModule = typeof import("@pithy-sh/media/src/provision/provisionMedia");
35
+ type MediaResolveModule = typeof import("@pithy-sh/media/src/provision/resolveMediaConfig");
36
+ type MediaRegistryModule = typeof import("@pithy-sh/media/src/secret/registry");
37
+ type MediaCapabilityModule = typeof import("@pithy-sh/media/src/capability");
38
+
39
+ /** The provisioner seams, referenced by type only so the CLI gains no dependency on the package. */
40
+ type MediaProvisioner = import("@pithy-sh/media/src/provision/provisionMedia").MediaProvisioner;
41
+ type MediaDeprovisioner = import("@pithy-sh/media/src/provision/provisionMedia").MediaDeprovisioner;
42
+ type MediaResources = import("@pithy-sh/media/src/provision/provisionMedia").MediaResources;
43
+ type MediaConfig = import("@pithy-sh/media/src/config/config").MediaConfig;
44
+
45
+ /** Everything `pithy media` loads out of the optional package, in one guarded import. */
46
+ export type MediaModule = MediaProvisionModule & MediaResolveModule & MediaRegistryModule & MediaCapabilityModule;
47
+
48
+ /**
49
+ * Load `@pithy-sh/media` from the project's own install. The one place the optional dependency is
50
+ * resolved, so a project that has not added media gets one clear instruction instead of a module error
51
+ * from whichever call site happened to run first.
52
+ */
53
+ export async function loadMedia(): Promise<MediaModule> {
54
+ try {
55
+ const [provision, resolve, registry, capability] = await Promise.all([
56
+ import("@pithy-sh/media/src/provision/provisionMedia"),
57
+ import("@pithy-sh/media/src/provision/resolveMediaConfig"),
58
+ import("@pithy-sh/media/src/secret/registry"),
59
+ import("@pithy-sh/media/src/capability"),
60
+ ]);
61
+ return { ...provision, ...resolve, ...registry, ...capability };
62
+ } catch (error) {
63
+ throw capabilityLoadError("media", "@pithy-sh/media", error);
64
+ }
65
+ }
66
+
67
+ /** The message of an unknown thrown value, for surfacing both legs of a failed upsert. */
68
+ function errorMessage(error: unknown): string {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
71
+
72
+ /**
73
+ * The routing facts the media storage secret carries — an encrypted D1 row, per-environment JSON.
74
+ *
75
+ * `d1` is where this value has always physically gone: the write below dispatches to the secrets
76
+ * manager Workflow, which stores it in `pithy_secrets_system_secrets`. No wrangler template ever bound
77
+ * it from the Cloudflare Secrets Store. These facts must agree with `mediaSecretsRegistry`'s
78
+ * declaration, because the read seam routes strictly on `backend` — a disagreement sends a deployed
79
+ * read to a binding that does not exist.
80
+ */
81
+ const SECRET_FACTS = {
82
+ backend: "d1",
83
+ scope: "environment",
84
+ rotatable: false,
85
+ valueType: "json",
86
+ } as const;
87
+
88
+ /** The per-environment resource ids the media worker binds, resolved by the caller. */
89
+ export interface MediaEnvResources {
90
+ /** The app database id for this environment — where media records and hashes live. */
91
+ appDatabaseId: string;
92
+ /** This environment's secrets database id (`<project>-<env>-secrets`) — holds the storage credentials. */
93
+ secretsDatabaseId: string;
94
+ }
95
+
96
+ /** Resolve the per-environment resources for the media worker (from the project wrangler + name lookups). */
97
+ export type ResolveMediaEnv = (env: ManagedEnvironment) => Promise<MediaEnvResources>;
98
+
99
+ export interface CloudflareMediaProvisionerOptions {
100
+ cf: CloudflareClients;
101
+ /**
102
+ * The account this provisions into, and what vouches for it (#378).
103
+ *
104
+ * Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
105
+ * already held while a find-or-create read an empty listing as "this account has none" and minted a
106
+ * real resource in whichever account the shell had named. The id is still here — `account.accountId` —
107
+ * and it now travels with the answer to "who says so".
108
+ */
109
+ account: ConfirmedAccount;
110
+ /**
111
+ * The project name, from `requireProjectName(await loadProject(projectDir))` — never
112
+ * `resolveProjectName`. Every name this provisioner creates, finds, and deletes leads with it, so a
113
+ * guessed value would stand up a second set of resources beside the real ones and tear down neither.
114
+ */
115
+ project: string;
116
+ /** The broad bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
117
+ apiToken: string;
118
+ /** The CF Secrets Store id holding the per-env master keys (the media worker decrypts its credentials). */
119
+ storeId: string;
120
+ /**
121
+ * The API token written into the media secret — what the Worker mints Images and Stream direct-upload
122
+ * URLs with. Supplied, not minted: the permission catalog carries no Images or Stream keys yet.
123
+ */
124
+ mediaApiToken: string;
125
+ /**
126
+ * The R2 S3 access-key pair the Worker presigns R2 uploads and downloads with. Supplied, not minted:
127
+ * Cloudflare exposes no API for creating one.
128
+ */
129
+ r2Credentials: { accessKeyId: string; secretAccessKey: string };
130
+ /**
131
+ * The API token carried alongside the R2 key pair so the object store can prove bucket access — a
132
+ * different scope from `mediaApiToken`, which is Images and Stream. Defaults to the bootstrap token,
133
+ * matching what `pithy storage provision` does; supply an R2-scoped one for production.
134
+ */
135
+ r2ApiToken?: string;
136
+ /** The app's resolved media config — decides whether a KV namespace is needed, and fills `MEDIA_CONFIG`. */
137
+ mediaConfig: MediaConfig;
138
+ /** The secrets manager dispatcher — writes the credentials into a deployed env's managed store. */
139
+ dispatcher: SecretDispatcher;
140
+ /** Resolve the per-env app DB id and secrets DB id — injected so it is testable + decoupled. */
141
+ resolveEnv: ResolveMediaEnv;
142
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
143
+ audit?: CliAuditEmit;
144
+ /** Every environment this project declares, from the root `pithy.config.ts` — the fan-out set for a `global` secret. */
145
+ environments: DeclaredEnvironments | readonly string[];
146
+ }
147
+
148
+ /** The live {@link MediaProvisioner}. Every step is idempotent, so provisioning is safe to re-run. */
149
+ export class CloudflareMediaProvisioner implements MediaProvisioner {
150
+ readonly #cf: CloudflareClients;
151
+ readonly #account: ConfirmedAccount;
152
+ readonly #project: string;
153
+ readonly #apiToken: string;
154
+ readonly #storeId: string;
155
+ readonly #mediaApiToken: string;
156
+ readonly #r2Credentials: { accessKeyId: string; secretAccessKey: string };
157
+ readonly #r2ApiToken: string;
158
+ readonly #mediaConfig: MediaConfig;
159
+ readonly #dispatcher: SecretDispatcher;
160
+ /**
161
+ * The project's declared environments (#241) — what a `global` secret write fans out across. Carried
162
+ * rather than assumed, so a shared secret reaches every environment the project deploys to.
163
+ */
164
+ readonly #environments: DeclaredEnvironments | readonly string[];
165
+ readonly #resolveEnv: ResolveMediaEnv;
166
+ readonly #audit: CliAuditEmit;
167
+
168
+ constructor(options: CloudflareMediaProvisionerOptions) {
169
+ this.#cf = options.cf;
170
+ this.#account = options.account;
171
+ this.#project = options.project;
172
+ this.#apiToken = options.apiToken;
173
+ this.#storeId = options.storeId;
174
+ this.#mediaApiToken = options.mediaApiToken;
175
+ this.#r2Credentials = options.r2Credentials;
176
+ this.#r2ApiToken = options.r2ApiToken ?? options.apiToken;
177
+ this.#mediaConfig = options.mediaConfig;
178
+ this.#dispatcher = options.dispatcher;
179
+ this.#environments = options.environments;
180
+ this.#resolveEnv = options.resolveEnv;
181
+ this.#audit = options.audit ?? (async () => {});
182
+ }
183
+
184
+ /** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy the Workflow-hosting worker. */
185
+ async preflight(): Promise<void> {
186
+ if (!(await this.#cf.workers().accountSubdomain())) {
187
+ throw new ValidationError({
188
+ message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
189
+ action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
190
+ });
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Reuse this environment's R2 bucket if it exists, otherwise create it.
196
+ *
197
+ * Find-then-create is only safe because the name carries the project: R2's namespace is flat and
198
+ * account-wide, so an unscoped name would make "reuse" mean "adopt whatever another Pithy project
199
+ * left here". R2 exposes no tags through the API, so the name is the whole ownership record — and
200
+ * the audit event writes the project down beside it.
201
+ */
202
+ async ensureBucket(env: ManagedEnvironment): Promise<{ bucketName: string }> {
203
+ const { mediaBucketName } = await loadMedia();
204
+ const name = mediaBucketName(this.#project, env);
205
+ const existing = await this.#cf.r2Provisioner().findBucketByName(name);
206
+ if (existing) return { bucketName: existing.name };
207
+ const created = await this.#cf.r2Provisioner().createBucket(name);
208
+ await this.#audit({
209
+ environment: env,
210
+ action: "media/bucket_created",
211
+ outcome: "success",
212
+ severity: "info",
213
+ resourceType: "cf_r2_bucket",
214
+ resourceId: created.name,
215
+ metadata: { name },
216
+ });
217
+ return { bucketName: created.name };
218
+ }
219
+
220
+ /** Reuse or create this environment's `MEDIA` KV namespace — but only when records live in KV. */
221
+ async ensureKvNamespace(env: ManagedEnvironment): Promise<{ namespaceId: string } | null> {
222
+ if (this.#mediaConfig.recordStore !== "kv") return null;
223
+ const { mediaKvTitle } = await loadMedia();
224
+ const title = mediaKvTitle(this.#project, env);
225
+ const existing = await this.#cf.kvProvisioner().findNamespaceByTitle(title);
226
+ if (existing) return { namespaceId: existing.id };
227
+ const created = await this.#cf.kvProvisioner().createNamespace(title);
228
+ await this.#audit({
229
+ environment: env,
230
+ action: "media/kv_namespace_created",
231
+ outcome: "success",
232
+ severity: "info",
233
+ resourceType: "cf_kv_namespace",
234
+ resourceId: created.id,
235
+ metadata: { title },
236
+ });
237
+ return { namespaceId: created.id };
238
+ }
239
+
240
+ /**
241
+ * Write the environment's two secrets. Upserts, so a re-run heals rather than fails.
242
+ *
243
+ * `media-storage-credentials` is media's own — the Images + Stream token and the account id.
244
+ * `media-r2-credentials` is `@pithy-sh/storage`'s R2 bundle, which media declares and never reads; it
245
+ * is validated against the schema the registry entry itself carries, so the value written here and the
246
+ * value the `ObjectStore` re-validates on read come from one declaration and cannot drift.
247
+ */
248
+ async writeCredentials(env: ManagedEnvironment, resources: MediaResources): Promise<void> {
249
+ const { MEDIA_STORAGE_SECRET, MEDIA_R2_SECRET, MediaStorageCredentials, mediaR2Registry } = await loadMedia();
250
+ // Validate before dispatching: a malformed secret is only discovered at the Worker's first read
251
+ // otherwise, long after the operator has walked away from the terminal.
252
+ const storage = MediaStorageCredentials.parse({
253
+ apiToken: this.#mediaApiToken,
254
+ accountId: this.#account.accountId,
255
+ });
256
+ const r2 = mediaR2Registry[MEDIA_R2_SECRET].schema.parse({
257
+ accountId: this.#account.accountId,
258
+ apiToken: this.#r2ApiToken,
259
+ accessKeyId: this.#r2Credentials.accessKeyId,
260
+ secretAccessKey: this.#r2Credentials.secretAccessKey,
261
+ bucket: resources.bucketName,
262
+ });
263
+ // The R2 secret goes first. It names the bucket, so a half-finished run leaves the object plane
264
+ // wired and the enrichment token missing — the failure an operator can read off the error.
265
+ await this.#upsertSecret(env, MEDIA_R2_SECRET, r2);
266
+ await this.#upsertSecret(env, MEDIA_STORAGE_SECRET, storage);
267
+ // One event for one provisioning step. Both names ride in the metadata rather than becoming two
268
+ // events, because nothing can write one secret and not the other — the pair is the unit.
269
+ await this.#audit({
270
+ environment: env,
271
+ action: "media/credentials_written",
272
+ outcome: "success",
273
+ severity: "info",
274
+ resourceType: "secret",
275
+ resourceId: MEDIA_STORAGE_SECRET,
276
+ metadata: { secrets: [MEDIA_STORAGE_SECRET, MEDIA_R2_SECRET] },
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Upsert one secret: create on first provision, update on a re-run (create rejects an existing
282
+ * secret) — so the write is idempotent. If create fails for a real reason the update almost always
283
+ * fails too; surface BOTH causes so the true failure isn't masked by the fallback's error.
284
+ */
285
+ async #upsertSecret(env: ManagedEnvironment, name: string, value: unknown): Promise<void> {
286
+ const write = { name, ...SECRET_FACTS, value: JSON.stringify(value), requested: env };
287
+ try {
288
+ await dispatchSecretWrite(this.#dispatcher, { mode: "create", ...write }, this.#environments);
289
+ } catch (createError) {
290
+ try {
291
+ await dispatchSecretWrite(this.#dispatcher, { mode: "update", ...write }, this.#environments);
292
+ } catch (updateError) {
293
+ throw new InternalError(
294
+ {
295
+ message: `Could not write the media storage credentials to ${env}.`,
296
+ action: "Check that `pithy secrets provision` has run for this environment, then re-run.",
297
+ detail: `${name}: create failed: ${errorMessage(createError)}; update failed: ${errorMessage(updateError)}`,
298
+ },
299
+ { cause: createError },
300
+ );
301
+ }
302
+ }
303
+ }
304
+
305
+ /** Resolve the env's wrangler config from the committed template + provisioned ids, then `wrangler deploy`. */
306
+ async deployWorker(env: ManagedEnvironment, resources: MediaResources): Promise<void> {
307
+ const { mediaWorkerName, resolveMediaConfig } = await loadMedia();
308
+ const { appDatabaseId, secretsDatabaseId } = await this.#resolveEnv(env);
309
+ const dir = await mediaWorkerDir();
310
+ const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
311
+ const config = resolveMediaConfig(template, {
312
+ project: this.#project,
313
+ env,
314
+ appDatabaseId,
315
+ secretsDatabaseId,
316
+ storeId: this.#storeId,
317
+ resources,
318
+ mediaConfig: this.#mediaConfig,
319
+ });
320
+ const configPath = join(dir, `.wrangler.${env}.json`);
321
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
322
+ try {
323
+ await runWrangler(["deploy", "--config", configPath], {
324
+ cwd: dir,
325
+ env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#account.accountId },
326
+ });
327
+ await this.#audit({
328
+ environment: env,
329
+ action: "media/worker_deployed",
330
+ outcome: "success",
331
+ severity: "info",
332
+ resourceType: "cf_worker",
333
+ resourceId: mediaWorkerName(this.#project, env),
334
+ });
335
+ } catch (error) {
336
+ await this.#audit({
337
+ environment: env,
338
+ action: "media/worker_deployed",
339
+ outcome: "failure",
340
+ severity: "info",
341
+ resourceType: "cf_worker",
342
+ resourceId: mediaWorkerName(this.#project, env),
343
+ });
344
+ throw error;
345
+ } finally {
346
+ await unlink(configPath).catch(() => {});
347
+ }
348
+ }
349
+ }
350
+
351
+ /** The directory of the prebuilt media worker inside the installed `@pithy-sh/media` package (holds wrangler.jsonc). */
352
+ async function mediaWorkerDir(): Promise<string> {
353
+ try {
354
+ return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/media/src/workflows/worker")));
355
+ } catch (error) {
356
+ throw capabilityLoadError("media", "@pithy-sh/media/src/workflows/worker", error);
357
+ }
358
+ }
359
+
360
+ export interface CloudflareMediaDeprovisionerOptions {
361
+ cf: CloudflareClients;
362
+ /** The project name, from `requireProjectName` — teardown finds resources by no other key. */
363
+ project: string;
364
+ /**
365
+ * The R2 S3 key pair, needed only when the bucket comes down. Emptying a bucket is an S3-protocol
366
+ * operation and R2 refuses to delete a non-empty one, so a bucket teardown cannot run on the API token
367
+ * alone. Omitted when `deleteStorage` is off and no bucket is touched.
368
+ */
369
+ r2Credentials?: R2Credentials;
370
+ /**
371
+ * The account this teardown deletes from, and what vouches for it (#378).
372
+ *
373
+ * Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
374
+ * miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
375
+ * exits 0. A caller that has not decided which account it is tearing down cannot compile.
376
+ */
377
+ account: ConfirmedAccount;
378
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
379
+ audit?: CliAuditEmit;
380
+ }
381
+
382
+ /**
383
+ * The live {@link MediaDeprovisioner} — removes each environment's media worker and, when asked, the R2
384
+ * bucket with every object in it plus the `MEDIA` KV namespace. Every step is guarded so a missing
385
+ * resource is a no-op: teardown is idempotent.
386
+ */
387
+ export class CloudflareMediaDeprovisioner implements MediaDeprovisioner {
388
+ readonly #cf: CloudflareClients;
389
+ readonly #project: string;
390
+ readonly #r2Credentials: R2Credentials | undefined;
391
+ readonly #account: ConfirmedAccount;
392
+ readonly #audit: CliAuditEmit;
393
+
394
+ constructor(options: CloudflareMediaDeprovisionerOptions) {
395
+ this.#cf = options.cf;
396
+ this.#project = options.project;
397
+ this.#r2Credentials = options.r2Credentials;
398
+ this.#account = options.account;
399
+ this.#audit = options.audit ?? (async () => {});
400
+ }
401
+
402
+ /** Delete the env's media worker if it is deployed. */
403
+ async deleteWorker(env: ManagedEnvironment): Promise<void> {
404
+ const { mediaWorkerName } = await loadMedia();
405
+ const name = mediaWorkerName(this.#project, env);
406
+ if (
407
+ await findOnConfirmedAccount({
408
+ ...this.#account,
409
+ what: `the ${name} Worker`,
410
+ find: () => this.#cf.workers().getWorker(name),
411
+ })
412
+ ) {
413
+ await this.#cf.workers().deleteWorker(name);
414
+ await this.#audit({
415
+ environment: env,
416
+ action: "media/worker_deleted",
417
+ outcome: "success",
418
+ severity: "warning",
419
+ resourceType: "cf_worker",
420
+ resourceId: name,
421
+ });
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Delete this environment's R2 bucket and every object in it, if it exists — destructive, and called
427
+ * only on an explicit storage teardown. The drain is not optional: R2 refuses to delete a bucket that
428
+ * still holds an object or a dangling multipart upload. What went is audited, not just that it went.
429
+ */
430
+ async deleteBucket(env: ManagedEnvironment): Promise<void> {
431
+ const { mediaBucketName } = await loadMedia();
432
+ const name = mediaBucketName(this.#project, env);
433
+ const teardown = await deleteR2BucketWithContents({
434
+ cf: this.#cf,
435
+ credentials: this.#r2Credentials,
436
+ bucketName: name,
437
+ });
438
+ if (!teardown.deleted) return;
439
+ await this.#audit({
440
+ environment: env,
441
+ action: "media/bucket_deleted",
442
+ outcome: "success",
443
+ severity: "warning",
444
+ resourceType: "cf_r2_bucket",
445
+ resourceId: name,
446
+ metadata: {
447
+ name,
448
+ objectsDeleted: teardown.objectsDeleted,
449
+ uploadsAborted: teardown.uploadsAborted,
450
+ },
451
+ });
452
+ }
453
+
454
+ /** Delete this environment's `MEDIA` KV namespace if it exists — destructive, only on an explicit storage teardown. */
455
+ async deleteKvNamespace(env: ManagedEnvironment): Promise<void> {
456
+ const { mediaKvTitle } = await loadMedia();
457
+ const title = mediaKvTitle(this.#project, env);
458
+ const existing = await this.#cf.kvProvisioner().findNamespaceByTitle(title);
459
+ if (!existing) return;
460
+ await this.#cf.kvProvisioner().deleteNamespace(existing.id);
461
+ await this.#audit({
462
+ environment: env,
463
+ action: "media/kv_namespace_deleted",
464
+ outcome: "success",
465
+ severity: "warning",
466
+ resourceType: "cf_kv_namespace",
467
+ resourceId: existing.id,
468
+ metadata: { title },
469
+ });
470
+ }
471
+ }