@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,303 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
7
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { managerWorkerName } from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
9
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
10
+ import { defineCommand } from "citty";
11
+ import { parse } from "comment-json";
12
+ import { createProjectCliAudit } from "../audit/cliAudit";
13
+ import {
14
+ CloudflareMediaDeprovisioner,
15
+ CloudflareMediaProvisioner,
16
+ loadMedia,
17
+ type MediaEnvResources,
18
+ } from "../capabilities/mediaProvisioner";
19
+ import { resolveR2Credentials } from "../capabilities/r2Bucket";
20
+ import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
21
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
22
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
23
+ import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "../project/config";
24
+ import { projectCapabilities, resolveWorkers } from "../project/workerScope";
25
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
26
+
27
+ /**
28
+ * `pithy media provision` / `deprovision` — the command the media manifest and wrangler template
29
+ * have always pointed at. It creates the R2 bucket (and the `MEDIA` KV namespace in KV record-store mode),
30
+ * writes the `media-storage-credentials` and `media-r2-credentials` secrets for every managed
31
+ * environment, and deploys the prebuilt media worker that hosts the four enrichment Workflows.
32
+ *
33
+ * Two secrets because two owners: the Images + Stream token is media's, and the R2 bundle belongs to
34
+ * `@pithy-sh/storage`'s `ObjectStore`, which media presigns through and whose key pair media never sees.
35
+ *
36
+ * **Credentials are supplied, not minted.** Cloudflare exposes no API for creating an R2 S3 access-key
37
+ * pair, and the permission catalog carries no Images or Stream keys, so the pair and the scoped API token
38
+ * come from flags or `.dev.vars` and are written into the secret as given. Minting them is a follow-up.
39
+ */
40
+
41
+ /**
42
+ * The audit emitter for a media command. Provisioning spans every managed environment at once, so there is
43
+ * no single target env to key the audit database on — `"dev"` is the fallback (mirrors `pithy email`'s
44
+ * convention for env-spanning commands). A no-op when creds or the audit capability aren't there.
45
+ */
46
+ async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
47
+ // `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
48
+ // single value is true for the run; each event states the environment it acted on.
49
+ return createProjectCliAudit({ projectDir, accountId, apiToken });
50
+ }
51
+
52
+ /** Load the media capability's resolved config from `pithy.config.ts`. */
53
+ async function loadMediaConfig(projectDir: string) {
54
+ const { isMediaCapability } = await loadMedia();
55
+ // Capabilities live in each Worker's `apps/<name>/pithy.config.ts`; provisioning is one
56
+ // project-wide decision, so the first Worker composing this capability provides it.
57
+ const capability = (await resolveWorkers({ projectDir }).then(projectCapabilities)).find(isMediaCapability);
58
+ if (!capability) {
59
+ throw new ValidationError({
60
+ message: "The media capability is not configured.",
61
+ action: "Add `media({ ... })` to pithy.config.ts (run `pithy add media`).",
62
+ });
63
+ }
64
+ return capability.mediaConfig;
65
+ }
66
+
67
+ /**
68
+ * The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
69
+ *
70
+ * The account is a parameter rather than an ambient, so this cannot resolve before something has
71
+ * established which account the project is for (#206).
72
+ *
73
+ * It also carries **what vouches for the account** (#378). A bare id is what every destructive and
74
+ * creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
75
+ * "I asked an account nothing claims" — the two arrive as one empty listing.
76
+ */
77
+ function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
78
+ account: ConfirmedAccount;
79
+ accountId: string;
80
+ apiToken: string;
81
+ storeId: string;
82
+ r2Raw: string | undefined;
83
+ } {
84
+ const vars = cloudflareEnv({ account });
85
+ const confirmation = cloudflareAccountConfirmation({ account });
86
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
87
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
88
+ const storeId = vars.SECRETS_STORE_ID ?? "";
89
+ if (!accountId || !apiToken) {
90
+ throw new ValidationError({
91
+ message: "Cloudflare credentials are missing.",
92
+ action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
93
+ });
94
+ }
95
+ if (!storeId) {
96
+ throw new ValidationError({
97
+ message: "The CF Secrets Store id is missing.",
98
+ action: "Run pithy add secrets to record SECRETS_STORE_ID (the media worker decrypts its credentials from it).",
99
+ });
100
+ }
101
+ return { account: { accountId, confirmation }, accountId, apiToken, storeId, r2Raw: vars.R2_CREDENTIALS };
102
+ }
103
+
104
+ /** A wrangler env stanza — only the fields the media worker deploy reads from the project's config. */
105
+ interface WranglerStanza {
106
+ d1_databases?: { binding: string; database_id?: string }[];
107
+ env?: Record<string, WranglerStanza | undefined>;
108
+ }
109
+
110
+ /**
111
+ * Resolve the per-environment resources the media worker binds, from the project's `wrangler.jsonc` (the
112
+ * app `DB` id per env) and a live lookup of the env's secrets database. Each missing value throws an
113
+ * actionable error rather than deploying a half-wired worker.
114
+ */
115
+ function buildResolveEnv(
116
+ projectDir: string,
117
+ cf: CloudflareClients,
118
+ /**
119
+ * The project name the secrets database is found by — `<project>-<env>-secrets`. Resolved once by the
120
+ * caller via `requireProjectName`, never guessed: the lookup is by name, so a wrong one either reports
121
+ * a database that "does not exist" or binds another project's secrets store.
122
+ */
123
+ project: string,
124
+ /**
125
+ * The account the secrets database is looked for on, and what vouches for it (#378).
126
+ *
127
+ * The refusal below reads a missing database as "provision it first". Against an account nothing
128
+ * claims, that database is missing because this run asked the wrong account — and the sentence sends
129
+ * an operator to run a provisioning command they have already run.
130
+ */
131
+ account: ConfirmedAccount,
132
+ ): (env: ManagedEnvironment) => Promise<MediaEnvResources> {
133
+ return async (env) => {
134
+ const config = parse(await readFile(join(projectDir, "wrangler.jsonc"), "utf8")) as unknown as WranglerStanza;
135
+ const stanza = config.env?.[env];
136
+ if (!stanza) {
137
+ throw new ValidationError({
138
+ message: `wrangler.jsonc has no env.${env} stanza.`,
139
+ action: `Add the ${env} environment to wrangler.jsonc with its DB binding.`,
140
+ });
141
+ }
142
+ const appDatabaseId = stanza.d1_databases?.find((db) => db.binding === "DB")?.database_id;
143
+ if (!appDatabaseId) {
144
+ throw new ValidationError({
145
+ message: `wrangler.jsonc env.${env} has no DB database_id.`,
146
+ action: `Provision the ${env} app database and set its id on the DB binding.`,
147
+ });
148
+ }
149
+ const secretsDb = await findOnConfirmedAccount({
150
+ ...account,
151
+ what: `the ${managerWorkerName(project, env)} database`,
152
+ find: () => cf.d1Provisioner().findDatabaseByName(managerWorkerName(project, env)),
153
+ });
154
+ if (!secretsDb) {
155
+ throw new ValidationError({
156
+ message: `The ${env} secrets database (${managerWorkerName(project, env)}) does not exist.`,
157
+ action: "Run `pithy secrets provision` first — the media worker reads its credentials from it.",
158
+ });
159
+ }
160
+ return { appDatabaseId, secretsDatabaseId: secretsDb.uuid };
161
+ };
162
+ }
163
+
164
+ const provision = defineCommand({
165
+ meta: {
166
+ name: "provision",
167
+ description: "Create the media bucket and namespace, write the credentials, and deploy the enrichment workers",
168
+ },
169
+ args: {
170
+ "api-token": {
171
+ type: "string",
172
+ description:
173
+ "Cloudflare API token the media Worker mints Images and Stream direct-upload URLs with. Defaults to CLOUDFLARE_API_TOKEN from .dev.vars — a broad token; supply a scoped Images + Stream token for production.",
174
+ },
175
+ "r2-access-key-id": {
176
+ type: "string",
177
+ description:
178
+ "R2 S3 access key id the Worker presigns R2 uploads and downloads with. Create the pair under R2 → Manage API tokens. Falls back to R2_CREDENTIALS in the account config.",
179
+ },
180
+ "r2-secret-access-key": {
181
+ type: "string",
182
+ description:
183
+ "R2 S3 secret access key, paired with --r2-access-key-id. Falls back to R2_CREDENTIALS in the account config.",
184
+ },
185
+ "r2-api-token": {
186
+ type: "string",
187
+ description:
188
+ "Cloudflare API token carried alongside the R2 key pair, so the object store can prove bucket access. Defaults to CLOUDFLARE_API_TOKEN from .dev.vars — a broad token; supply an R2-scoped one for production.",
189
+ },
190
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
191
+ },
192
+ run: ({ args }) =>
193
+ withErrorReporting(args.json, async () => {
194
+ const projectDir = process.cwd();
195
+ // The leading segment of every name this run creates — the bucket, the KV namespace, the worker.
196
+ // `requireProjectName` refuses to guess, because `deprovision` recomputes these same names to
197
+ // find what to delete (docs/NAMING.md).
198
+ const config = await loadProject(projectDir);
199
+ const project = requireProjectName(config);
200
+ // The project's own environment set (#241): what this command fans out across, rather than a
201
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
202
+ const environments = loadProjectEnvironments(config);
203
+ const { provisionMedia } = await loadMedia();
204
+ const { account, accountId, apiToken, storeId, r2Raw } = loadCloudflareCreds(
205
+ await projectCloudflareAccount(projectDir),
206
+ );
207
+ const mediaConfig = await loadMediaConfig(projectDir);
208
+ const r2Credentials = resolveR2Credentials(args["r2-access-key-id"], args["r2-secret-access-key"], r2Raw);
209
+ const cf = new CloudflareClients({ accountId, apiToken });
210
+ const provisioner = new CloudflareMediaProvisioner({
211
+ cf,
212
+ project,
213
+ environments,
214
+ account,
215
+ apiToken,
216
+ storeId,
217
+ mediaApiToken: args["api-token"] ?? apiToken,
218
+ r2Credentials,
219
+ r2ApiToken: args["r2-api-token"] ?? apiToken,
220
+ mediaConfig,
221
+ dispatcher: buildSecretDispatcher(accountId, apiToken, project),
222
+ resolveEnv: buildResolveEnv(projectDir, cf, project, account),
223
+ audit: await buildAudit(projectDir, accountId, apiToken),
224
+ });
225
+
226
+ const result = await provisionMedia(provisioner, environments);
227
+
228
+ if (args.json) {
229
+ process.stdout.write(`${formatJsonLine({ command: "media provision", ...result })}\n`);
230
+ return;
231
+ }
232
+ for (const entry of result.environments) {
233
+ const namespace = entry.kvNamespaceId ? " and its MEDIA namespace" : "";
234
+ process.stdout.write(`${entry.env}: bucket ${entry.bucketName}${namespace} ready, worker deployed.\n`);
235
+ }
236
+ process.stdout.write(`${formatDone()}\n`);
237
+ }),
238
+ });
239
+
240
+ const deprovision = defineCommand({
241
+ meta: { name: "deprovision", description: "Remove the media workers (and optionally the bucket and namespace)" },
242
+ args: {
243
+ storage: {
244
+ type: "boolean",
245
+ default: false,
246
+ description: "Also delete the R2 bucket with every object in it, and the MEDIA KV namespace (irreversible)",
247
+ },
248
+ "r2-access-key-id": {
249
+ type: "string",
250
+ description:
251
+ "R2 S3 access key id, required with --storage: a bucket must be emptied over the S3 protocol before R2 will delete it. Falls back to R2_CREDENTIALS in the account config.",
252
+ },
253
+ "r2-secret-access-key": {
254
+ type: "string",
255
+ description:
256
+ "R2 S3 secret access key, paired with --r2-access-key-id. Falls back to R2_CREDENTIALS in the account config.",
257
+ },
258
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
259
+ },
260
+ run: ({ args }) =>
261
+ withErrorReporting(args.json, async () => {
262
+ const projectDir = process.cwd();
263
+ // Teardown finds resources by recomputing their names, so this must be the same name
264
+ // `provision` used. A guess would match nothing, delete nothing, and still exit 0.
265
+ const config = await loadProject(projectDir);
266
+ const project = requireProjectName(config);
267
+ // The project's own environment set (#241): what this command fans out across, rather than a
268
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
269
+ const environments = loadProjectEnvironments(config);
270
+ const { deprovisionMedia } = await loadMedia();
271
+ const { account, accountId, apiToken, r2Raw } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
272
+ // Resolve the key pair up front, before a single worker comes down. A bucket cannot be deleted
273
+ // without it, so discovering it is missing at the bucket step would leave the media workers gone
274
+ // and the bucket standing — a half-torn-down environment for a mistake we can catch here.
275
+ const r2Credentials = args.storage
276
+ ? resolveR2Credentials(args["r2-access-key-id"], args["r2-secret-access-key"], r2Raw)
277
+ : undefined;
278
+ const cf = new CloudflareClients({ accountId, apiToken });
279
+ const deprovisioner = new CloudflareMediaDeprovisioner({
280
+ account,
281
+ cf,
282
+ project,
283
+ r2Credentials,
284
+ audit: await buildAudit(projectDir, accountId, apiToken),
285
+ });
286
+
287
+ await deprovisionMedia(deprovisioner, environments, { deleteStorage: args.storage });
288
+
289
+ if (args.json) {
290
+ process.stdout.write(`${formatJsonLine({ command: "media deprovision", storageDeleted: args.storage })}\n`);
291
+ return;
292
+ }
293
+ process.stdout.write(
294
+ `Media workers removed${args.storage ? ", including the bucket, its objects, and the namespace" : ""}.\n`,
295
+ );
296
+ process.stdout.write(`${formatDone()}\n`);
297
+ }),
298
+ });
299
+
300
+ export default defineCommand({
301
+ meta: { name: "media", description: "Provision and manage the media infrastructure" },
302
+ subCommands: { provision, deprovision },
303
+ });
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { defineCommand } from "citty";
5
+ import {
6
+ type MigrationProgress,
7
+ migratedBeforeFailure,
8
+ migrateProject,
9
+ type WorkerMigrationRun,
10
+ } from "../migrations/run";
11
+ import { loadProject, projectCloudflareAccount, requireProjectName } from "../project/config";
12
+ import { ENV_ARG, requireEnvironment } from "../project/environment";
13
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
14
+
15
+ /**
16
+ * One line per worker: which migrations moved, in which direction (docs/CLI.md §3). A worker's
17
+ * databases are folded into one line — the migration names already carry their capability namespace,
18
+ * and a run is read worker by worker.
19
+ */
20
+ function describe(run: WorkerMigrationRun, rollback: boolean): string {
21
+ const names = run.databases.flatMap((database) => database.results.map((result) => result.migrationName));
22
+ if (names.length === 0) return `nothing to ${rollback ? "roll back" : "apply"}.`;
23
+ return `${names.join(", ")} ${rollback ? "rolled back" : "applied"}.`;
24
+ }
25
+
26
+ /**
27
+ * Render a fan-out run: one worker per line, whitespace-aligned (docs/CLI.md §3.5), or the single
28
+ * `--json` line whose `workers` array groups the run exactly as the human output does. Split out so the
29
+ * output contract is testable without a project on disk.
30
+ */
31
+ export function formatMigrateReport(
32
+ workers: WorkerMigrationRun[],
33
+ options: { project: string; env: string; rollback: boolean; json: boolean },
34
+ ): string {
35
+ if (options.json) {
36
+ const payload = { command: "migrate", project: options.project, env: options.env, rollback: options.rollback };
37
+ return `${formatJsonLine({ ...payload, workers })}\n`;
38
+ }
39
+ if (workers.every((worker) => worker.databases.length === 0)) return `Nothing to migrate.\n${formatDone()}\n`;
40
+
41
+ const width = Math.max(...workers.map((worker) => worker.worker.length));
42
+ const lines = workers.map((worker) => `${worker.worker.padEnd(width)} ${describe(worker, options.rollback)}`);
43
+ return `${lines.join("\n")}\n${formatDone()}\n`;
44
+ }
45
+
46
+ /**
47
+ * What a run that died partway did before it died (#380).
48
+ *
49
+ * A fan-out has no transaction across databases: the third one throws and the first two are already
50
+ * ahead of it. Until now the throw took the whole report with it, so the operator was told a migration
51
+ * failed and nothing about which schemas had moved — on the one command where that is the first
52
+ * question. `withErrorReporting` writes the failure to stderr and exits 1; this writes what the run did
53
+ * to stdout first, so both streams and the exit code agree that it failed and name what it changed.
54
+ *
55
+ * The three states are kept apart on purpose. A database that migrated, the one that failed, and one
56
+ * the run never opened are three different things to do next, and a single list would make them one.
57
+ */
58
+ export function formatMigrateProgress(
59
+ progress: MigrationProgress,
60
+ options: { project: string; env: string; rollback: boolean; json: boolean },
61
+ ): string {
62
+ if (options.json) {
63
+ return `${formatJsonLine({
64
+ command: "migrate",
65
+ project: options.project,
66
+ env: options.env,
67
+ rollback: options.rollback,
68
+ workers: progress.migrated,
69
+ failed: progress.failed,
70
+ unreached: progress.unreached,
71
+ interrupted: true,
72
+ })}\n`;
73
+ }
74
+ const lines = progress.migrated
75
+ .filter((worker) => worker.databases.length > 0)
76
+ .map((worker) => `${worker.worker} ${describe(worker, options.rollback)}`);
77
+ lines.push(
78
+ `${progress.failed.binding} (${progress.failed.database}) failed. Its schema is where the failure left it.`,
79
+ );
80
+ if (progress.unreached.length > 0) {
81
+ const named = progress.unreached.map((target) => `${target.binding} (${target.database})`).join(", ");
82
+ lines.push(`Not reached: ${named}.`);
83
+ }
84
+ return `${lines.join("\n")}\n`;
85
+ }
86
+
87
+ export default defineCommand({
88
+ meta: { name: "migrate", description: "Run migrations for an environment" },
89
+ args: {
90
+ env: ENV_ARG,
91
+ worker: { type: "string", description: "Migrate one worker instead of every worker in apps/" },
92
+ rollback: { type: "boolean", default: false, description: "Step the latest migration back" },
93
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
94
+ },
95
+ run: ({ args }) =>
96
+ withErrorReporting(args.json, async () => {
97
+ const env = requireEnvironment(args.env);
98
+ const projectDir = process.cwd();
99
+ // The non-guessing name: it is stamped into every database this run touches, and a later run
100
+ // checks against it, so a fallback that differs between checkouts would lock a project out of
101
+ // its own database. `requireProjectName` refuses to guess (docs/CLI.md §3.3).
102
+ const project = requireProjectName(await loadProject(projectDir));
103
+ // And the account this project belongs to, before anything resolves a credential. `migrateProject`
104
+ // states the hazard in its own words: a remote migration alters a real schema, so the wrong
105
+ // account's credentials would run it against another company's database (#206). This command is
106
+ // the one that has to supply the answer, and for a long while it did not.
107
+ const account = await projectCloudflareAccount(projectDir);
108
+ const render = { project, env, rollback: args.rollback, json: args.json };
109
+ let workers: WorkerMigrationRun[];
110
+ try {
111
+ workers = await migrateProject({
112
+ projectDir,
113
+ project,
114
+ account,
115
+ env,
116
+ ...(args.worker !== undefined ? { worker: args.worker } : {}),
117
+ rollback: args.rollback,
118
+ });
119
+ } catch (error) {
120
+ // A run that failed on the third database has already moved the first two, and until #380 the
121
+ // report of it died with the throw. What ran is printed here, then the same error is rethrown
122
+ // unchanged for `withErrorReporting` to render and exit 1 on.
123
+ const progress = migratedBeforeFailure(error);
124
+ if (progress) process.stdout.write(formatMigrateProgress(progress, render));
125
+ throw error;
126
+ }
127
+ process.stdout.write(formatMigrateReport(workers, render));
128
+ }),
129
+ });