@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,331 @@
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 type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
9
+ import { defineCommand } from "citty";
10
+ import { parse } from "comment-json";
11
+ import { createProjectCliAudit } from "../audit/cliAudit";
12
+ import { resolveR2Credentials } from "../capabilities/r2Bucket";
13
+ import {
14
+ CloudflareSupportDeprovisioner,
15
+ CloudflareSupportProvisioner,
16
+ loadSupport,
17
+ type SupportEnvResources,
18
+ } from "../capabilities/supportProvisioner";
19
+ import type { ConfirmedAccount } from "../cloudflare/accountAnswer";
20
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
21
+ import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "../project/config";
22
+ import { projectCapabilities, type ResolvedWorker, resolveSingleWorker, resolveWorkers } from "../project/workerScope";
23
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
24
+
25
+ /**
26
+ * `pithy support provision` / `deprovision` — the command the support manifest and wrangler
27
+ * template have always pointed at. It creates the `SUPPORT_BUCKET` R2 bucket, deploys the prebuilt
28
+ * classification worker for every managed environment, and creates the Email Routing rule that delivers
29
+ * the support address to the app worker.
30
+ *
31
+ * **The routing flags are all-or-nothing, and deliberately explicit.** Enabling Email Routing on a zone
32
+ * points its MX at Cloudflare, so a rule created on the wrong zone moves an adopter's real inbound mail
33
+ * off their existing provider. That is not a mistake a provisioning command gets to make on somebody's
34
+ * behalf, so the zone, the address, and the target worker are each named or the rule is not created —
35
+ * everything else provisions, and the rule is added when the operator has decided.
36
+ *
37
+ * No secret is written. The classification worker reads a message and writes a label over the `AI`
38
+ * binding, so it carries no credential; the R2 key pair support presigns attachments with belongs to
39
+ * `@pithy-sh/storage` and is written by `pithy storage provision`.
40
+ */
41
+
42
+ /**
43
+ * The audit emitter for a support command. Provisioning spans every managed environment at once, so there
44
+ * is no single target env to key the audit database on — `"dev"` is the fallback (the convention `pithy
45
+ * email` and `pithy media` already use for env-spanning commands). A no-op when creds or the audit
46
+ * capability aren't there.
47
+ */
48
+ async function buildAudit(projectDir: string, accountId: string, apiToken: string, worker?: string) {
49
+ // `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
50
+ // single value is true for the run; each event states the environment it acted on.
51
+ return createProjectCliAudit({ projectDir, accountId, apiToken, ...(worker !== undefined ? { worker } : {}) });
52
+ }
53
+
54
+ /** Load the support capability's resolved config from `pithy.config.ts`. */
55
+ async function loadSupportConfig(projectDir: string) {
56
+ const { isSupportCapability } = await loadSupport();
57
+ // Capabilities live in each Worker's `apps/<name>/pithy.config.ts`; provisioning is one project-wide
58
+ // decision, so the first Worker composing this capability provides it.
59
+ const capability = (await resolveWorkers({ projectDir }).then(projectCapabilities)).find(isSupportCapability);
60
+ if (!capability) {
61
+ throw new ValidationError({
62
+ message: "The support capability is not configured.",
63
+ action: "Add `support({ ... })` to a worker's pithy.config.ts (run `pithy add support`).",
64
+ });
65
+ }
66
+ return capability.supportConfig;
67
+ }
68
+
69
+ /**
70
+ * The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
71
+ *
72
+ * The account is a parameter rather than an ambient, so this cannot resolve before something has
73
+ * established which account the project is for (#206).
74
+ *
75
+ * It also carries **what vouches for the account** (#378). A bare id is what every destructive and
76
+ * creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
77
+ * "I asked an account nothing claims" — the two arrive as one empty listing.
78
+ */
79
+ function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
80
+ account: ConfirmedAccount;
81
+ accountId: string;
82
+ apiToken: string;
83
+ r2Raw: string | undefined;
84
+ } {
85
+ const vars = cloudflareEnv({ account });
86
+ const confirmation = cloudflareAccountConfirmation({ account });
87
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
88
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
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
+ // No Secrets Store id, unlike email and media: the classification worker binds `DB` and `AI` and holds
96
+ // no credential to decrypt.
97
+ return { account: { accountId, confirmation }, accountId, apiToken, r2Raw: vars.R2_CREDENTIALS };
98
+ }
99
+
100
+ /** A wrangler env stanza — only the field the support worker deploy reads from a Worker's config. */
101
+ interface WranglerStanza {
102
+ d1_databases?: { binding: string; database_id?: string }[];
103
+ env?: Record<string, WranglerStanza | undefined>;
104
+ }
105
+
106
+ /**
107
+ * Resolve the per-environment app database the classification worker binds, from the **app Worker's**
108
+ * `wrangler.jsonc`. A missing stanza or id throws an actionable error rather than deploying a worker that
109
+ * would write its classifications into nothing.
110
+ *
111
+ * Which Worker is the app Worker? Every Worker owns its own `wrangler.jsonc`, so a project with several
112
+ * names one with `--worker`; one Worker needs no ceremony. Workers sharing a database share the `DB`
113
+ * binding name, so any Worker carrying the support tables answers the same id.
114
+ */
115
+ function buildResolveEnv(worker: ResolvedWorker): (env: ManagedEnvironment) => Promise<SupportEnvResources> {
116
+ return async (env) => {
117
+ const path = join(worker.dir, "wrangler.jsonc");
118
+ const config = parse(await readFile(path, "utf8")) as unknown as WranglerStanza;
119
+ const stanza = config.env?.[env];
120
+ if (!stanza) {
121
+ throw new ValidationError({
122
+ message: `${worker.name}'s wrangler.jsonc has no env.${env} stanza.`,
123
+ action: `Add the ${env} environment to ${path} with its DB binding.`,
124
+ });
125
+ }
126
+ const appDatabaseId = stanza.d1_databases?.find((db) => db.binding === "DB")?.database_id;
127
+ if (!appDatabaseId) {
128
+ throw new ValidationError({
129
+ message: `${worker.name}'s wrangler.jsonc env.${env} has no DB database_id.`,
130
+ action: `Provision the ${env} app database and set its id on the DB binding.`,
131
+ });
132
+ }
133
+ return { appDatabaseId };
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The three routing flags, together or not at all.
139
+ *
140
+ * A partial set is rejected rather than quietly treated as "no routing": an operator who passed two of
141
+ * three asked for a rule, and silently provisioning everything but the one step that delivers the mail
142
+ * would look like success and receive nothing.
143
+ */
144
+ function resolveRouting(
145
+ zoneId: string | undefined,
146
+ address: string | undefined,
147
+ appWorkerName: string | undefined,
148
+ ): { zoneId: string; address: string; appWorkerName: string } | undefined {
149
+ if (zoneId && address && appWorkerName) return { zoneId, address, appWorkerName };
150
+ if (!zoneId && !address && !appWorkerName) return undefined;
151
+ throw new ValidationError({
152
+ message: "The inbound routing options are incomplete.",
153
+ action: "Pass --routing-zone, --inbound-address, and --app-worker together, or none of them.",
154
+ });
155
+ }
156
+
157
+ const provision = defineCommand({
158
+ meta: {
159
+ name: "provision",
160
+ description: "Create the support bucket, deploy the classification workers, and route the inbound address",
161
+ },
162
+ args: {
163
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
164
+ worker: {
165
+ type: "string",
166
+ description:
167
+ "The app worker whose wrangler.jsonc carries the per-environment DB binding (default: the project's only worker)",
168
+ },
169
+ "routing-zone": {
170
+ type: "string",
171
+ description:
172
+ "Cloudflare Zone ID of the (sub)domain receiving the mail — Email Routing must already be enabled on it (its MX points to Cloudflare). Use a subdomain zone (e.g. help.example.com), never your apex, so your primary MX is untouched. Find it on the zone's Overview page.",
173
+ },
174
+ "inbound-address": {
175
+ type: "string",
176
+ description:
177
+ "The exact recipient address the rule matches (e.g. support@help.example.com); mail sent to it is delivered to the app worker's email() handler. It must also be listed in support()'s inboundAddresses, which is what claims it.",
178
+ },
179
+ "app-worker": {
180
+ type: "string",
181
+ description:
182
+ "Deployed name of your production app worker — the one running createEntrypoint with the support capability composed (e.g. pithy-app-prod).",
183
+ },
184
+ },
185
+ run: ({ args }) =>
186
+ withErrorReporting(args.json, async () => {
187
+ const projectDir = process.cwd();
188
+ // The leading segment of the bucket, the classification workers, and the routing rule. The bucket
189
+ // is found by name and reused, so `requireProjectName` refuses to guess — a guessed name adopts
190
+ // another project's inbox (docs/NAMING.md).
191
+ const config = await loadProject(projectDir);
192
+ const project = requireProjectName(config);
193
+ // The project's own environment set (#241): what this command fans out across, rather than a
194
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
195
+ const environments = loadProjectEnvironments(config);
196
+ const { provisionSupport } = await loadSupport();
197
+ const { account, accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
198
+ const supportConfig = await loadSupportConfig(projectDir);
199
+ const appWorker = await resolveSingleWorker({
200
+ projectDir,
201
+ ...(args.worker !== undefined ? { worker: args.worker } : {}),
202
+ });
203
+ const routing = resolveRouting(args["routing-zone"], args["inbound-address"], args["app-worker"]);
204
+ const provisioner = new CloudflareSupportProvisioner({
205
+ cf: new CloudflareClients({ accountId, apiToken }),
206
+ project,
207
+ account,
208
+ apiToken,
209
+ supportConfig,
210
+ resolveEnv: buildResolveEnv(appWorker),
211
+ ...(routing !== undefined ? { routing } : {}),
212
+ audit: await buildAudit(projectDir, accountId, apiToken, args.worker),
213
+ });
214
+
215
+ const result = await provisionSupport(provisioner, environments);
216
+
217
+ if (args.json) {
218
+ process.stdout.write(`${formatJsonLine({ command: "support provision", ...result })}\n`);
219
+ return;
220
+ }
221
+ process.stdout.write(
222
+ result.bucket.skipped ? "Attachments are off. No bucket created.\n" : `Bucket ${result.bucket.bucket} ready.\n`,
223
+ );
224
+ process.stdout.write(`${result.environments.length} classification workers deployed.\n`);
225
+ // Say what happened to the index. It is DDL on the adopter's app database, and a provisioning
226
+ // command that silently creates or drops a table is one an operator cannot audit by reading its
227
+ // output. Silence here means it already matched the config, which is also worth saying.
228
+ const created = result.search.filter((entry) => entry.created).map((entry) => entry.env);
229
+ const dropped = result.search.filter((entry) => entry.dropped).map((entry) => entry.env);
230
+ if (created.length > 0) process.stdout.write(`Search index created in ${created.join(", ")}.\n`);
231
+ if (dropped.length > 0) process.stdout.write(`Search index dropped in ${dropped.join(", ")}.\n`);
232
+ if (created.length === 0 && dropped.length === 0) {
233
+ process.stdout.write("Search index already matches your config.\n");
234
+ }
235
+ // Say plainly when no rule was made. Everything else can be right and the inbox still receive
236
+ // nothing, so this is the line an operator needs to read.
237
+ process.stdout.write(
238
+ routing
239
+ ? `Inbound mail for ${routing.address} routes to ${routing.appWorkerName}.\n`
240
+ : "No routing rule. Pass --routing-zone, --inbound-address, and --app-worker to create one.\n",
241
+ );
242
+ process.stdout.write(`${formatDone()}\n`);
243
+ }),
244
+ });
245
+
246
+ const deprovision = defineCommand({
247
+ meta: {
248
+ name: "deprovision",
249
+ description: "Remove the routing rule and the classification workers (optionally the bucket)",
250
+ },
251
+ args: {
252
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
253
+ worker: {
254
+ type: "string",
255
+ description: "The app worker whose wrangler.jsonc names the database the audit trail is written to",
256
+ },
257
+ storage: {
258
+ type: "boolean",
259
+ default: false,
260
+ description:
261
+ "Also delete the R2 bucket with every attachment and raw message in it (irreversible — this is your support history)",
262
+ },
263
+ "routing-zone": {
264
+ type: "string",
265
+ description:
266
+ "Cloudflare Zone ID the inbound rule lives on. Without it the rule is left in place and mail keeps arriving, because a rule is addressed through its zone and this command will not sweep your domains looking for one.",
267
+ },
268
+ "r2-access-key-id": {
269
+ type: "string",
270
+ description:
271
+ "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.",
272
+ },
273
+ "r2-secret-access-key": {
274
+ type: "string",
275
+ description:
276
+ "R2 S3 secret access key, paired with --r2-access-key-id. Falls back to R2_CREDENTIALS in the account config.",
277
+ },
278
+ },
279
+ run: ({ args }) =>
280
+ withErrorReporting(args.json, async () => {
281
+ const projectDir = process.cwd();
282
+ // Teardown finds resources by recomputing their names, so this must be the same name
283
+ // `provision` used. A guess would match nothing, delete nothing, and still exit 0.
284
+ const config = await loadProject(projectDir);
285
+ const project = requireProjectName(config);
286
+ // The project's own environment set (#241): what this command fans out across, rather than a
287
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
288
+ const environments = loadProjectEnvironments(config);
289
+ const { deprovisionSupport } = await loadSupport();
290
+ const { account, accountId, apiToken, r2Raw } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
291
+ // Resolve the key pair up front, before a single worker comes down. A bucket cannot be deleted
292
+ // without it, so discovering it is missing at the bucket step would leave the workers gone and the
293
+ // bucket standing — a half-torn-down inbox for a mistake we can catch here.
294
+ const r2Credentials = args.storage
295
+ ? resolveR2Credentials(args["r2-access-key-id"], args["r2-secret-access-key"], r2Raw)
296
+ : undefined;
297
+ const deprovisioner = new CloudflareSupportDeprovisioner({
298
+ account,
299
+ cf: new CloudflareClients({ accountId, apiToken }),
300
+ project,
301
+ ...(args["routing-zone"] !== undefined ? { routingZoneId: args["routing-zone"] } : {}),
302
+ ...(r2Credentials !== undefined ? { r2Credentials } : {}),
303
+ audit: await buildAudit(projectDir, accountId, apiToken, args.worker),
304
+ });
305
+
306
+ await deprovisionSupport(deprovisioner, environments, { deleteStorage: args.storage });
307
+
308
+ if (args.json) {
309
+ process.stdout.write(
310
+ `${formatJsonLine({
311
+ command: "support deprovision",
312
+ storageDeleted: args.storage,
313
+ routingZone: args["routing-zone"] ?? null,
314
+ })}\n`,
315
+ );
316
+ return;
317
+ }
318
+ process.stdout.write(
319
+ `Support workers removed${args.storage ? ", including the bucket and everything in it" : ""}.\n`,
320
+ );
321
+ if (!args["routing-zone"]) {
322
+ process.stdout.write("The routing rule was left in place. Pass --routing-zone to remove it.\n");
323
+ }
324
+ process.stdout.write(`${formatDone()}\n`);
325
+ }),
326
+ });
327
+
328
+ export default defineCommand({
329
+ meta: { name: "support", description: "Provision and manage the support inbox infrastructure" },
330
+ subCommands: { provision, deprovision },
331
+ });