@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,368 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { environmentScope } from "@pithy-sh/core/src/naming/provisionScope";
8
+ import { defineCommand } from "citty";
9
+ import { type CliAuditEmit, createCliAudit } from "../audit/cliAudit";
10
+ import { storeSecretMinter } from "../capabilities/mintSecrets";
11
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
12
+ import { branchIdentity } from "../feature/identity";
13
+ import { provisionFeature } from "../feature/provision";
14
+ import {
15
+ loadProject,
16
+ loadProjectCloudflare,
17
+ loadProjectEnvironments,
18
+ projectCloudflareAccount,
19
+ requireProjectName,
20
+ } from "../project/config";
21
+ import { requireManagedEnvironment } from "../project/environment";
22
+ import { projectCapabilities, resolveWorkers } from "../project/workerScope";
23
+ import { assertProvisionConfirmed, provisionConfirmPhrase } from "../provision/confirm";
24
+ import { type ProvisionReport, provisionEnvironment } from "../provision/environment";
25
+ import { type ProvisionMode, requireProvisionMode } from "../provision/mode";
26
+ import { type PendingSecrets, pendingSecretLines, pendingSecrets } from "../provision/pendingSecrets";
27
+ import { AUDIT_DESTINATION_ENV, cloudflareProvisioners, type ResourceProvisioners } from "../provision/resources";
28
+ import { secretsStoreBindings, workerSecretRegistry } from "../provision/secretBindings";
29
+ import { cloudflareSecretsStore, type SecretsStore } from "../provision/store";
30
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
31
+
32
+ /**
33
+ * `pithy provision --env <name>` and `pithy provision --feature` — **one command, because provisioning is
34
+ * one job.**
35
+ *
36
+ * Both create an environment's Cloudflare resources, write their ids into each Worker's config, and
37
+ * migrate. They differ only in how the target environment is *named*: declared in the root
38
+ * `pithy.config.ts`, or derived from the checked-out branch. That is a flag, not a different verb.
39
+ *
40
+ * **The safety is in the scope, not in the spelling.** A `ProvisionScope` carries the resource naming and
41
+ * the `env.<name>` stanza the ids are written into, as one value (#240) — so a feature-named resource
42
+ * landing in a declared environment's stanza of a checked-in config is unexpressible rather than merely
43
+ * refused. Nothing about that depends on which words were typed, which is what leaves one command free to
44
+ * carry both modes.
45
+ *
46
+ * **The one real difference is persistence, and the command says so on every run.** `--env` writes
47
+ * `env.<name>` into the tracked `wrangler.jsonc`: long-lived ids a human reviews in a pull request.
48
+ * `--feature` writes a generated config under the already-ignored `.wrangler/`: one job's output,
49
+ * rebuilt every run and never committed. A single flag that flips whether output is committed will
50
+ * eventually surprise someone, so each run names the file it wrote and whether that file is committed,
51
+ * in the human summary and as `--json`'s `committed`. It is also what keeps the standing rule checkable
52
+ * rather than remembered — **a CI build process never commits back to the repository**: a pipeline runs
53
+ * `--feature` and has nothing to commit.
54
+ *
55
+ * **It is still its own command, and `deploy` refuses rather than calling it.** A deploy that silently
56
+ * creates account resources is hard to review. `pithy deploy --env staging` names this command instead of
57
+ * failing inside wrangler.
58
+ *
59
+ * **There is no `pithy deprovision`.** `pithy feature destroy` reverses a branch's environment because a
60
+ * branch's environment is disposable. Staging and production are not, and the one-word difference between
61
+ * the two is not a difference a flag should carry.
62
+ */
63
+
64
+ /** Build the CF control-plane provisioners from the environment's credentials, or null when they are absent. */
65
+ function buildProvisioners(account: CloudflareAccountSelection | null): ResourceProvisioners | null {
66
+ const vars = cloudflareEnv({ account });
67
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
68
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
69
+ if (!accountId || !apiToken) return null;
70
+ // What vouches for that id travels with it (#378). `find` is find-or-create's first half, and an empty
71
+ // listing from an account nothing claims is not the absence the second half reads it as.
72
+ const confirmation = cloudflareAccountConfirmation({ account });
73
+ return cloudflareProvisioners(new CloudflareClients({ accountId, apiToken }), { accountId, confirmation });
74
+ }
75
+
76
+ /**
77
+ * The account's Secrets Store, or `null` when this project has recorded no store id.
78
+ *
79
+ * Absent is a degraded environment, never a failed command: a project composing no `secrets` capability
80
+ * needs no store, and one that does gets its `secrets_store_secrets` stanza — the binding `pithy add`
81
+ * deliberately could not write, and nothing came back for (#238).
82
+ */
83
+ function buildStore(account: CloudflareAccountSelection | null): SecretsStore | null {
84
+ const vars = cloudflareEnv({ account });
85
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
86
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
87
+ const storeId = vars.SECRETS_STORE_ID ?? "";
88
+ if (!accountId || !apiToken || !storeId) return null;
89
+ return cloudflareSecretsStore(new CloudflareClients({ accountId, apiToken }), storeId);
90
+ }
91
+
92
+ /**
93
+ * The audit emitter. Provisioning creates real infrastructure under a real token, and it runs headlessly
94
+ * in CI, so every creation leaves a record of what was made and under whose credentials.
95
+ *
96
+ * The trail lands in the project's own top-level database — the environment being provisioned may not
97
+ * have one yet, which is the whole point of the command.
98
+ */
99
+ async function buildAudit(
100
+ projectDir: string,
101
+ capabilities: Capability[],
102
+ account: CloudflareAccountSelection | null,
103
+ ): Promise<CliAuditEmit> {
104
+ const vars = cloudflareEnv({ account });
105
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
106
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
107
+ if (!accountId || !apiToken) return async () => {};
108
+ return createCliAudit({
109
+ projectDir,
110
+ // Routing, not truth: each event names the environment it acted on. Claiming this as `actedOn`
111
+ // would blame `dev` for a change to production — the regression `auditDestination.test.ts` pins.
112
+ env: AUDIT_DESTINATION_ENV,
113
+ capabilities,
114
+ clients: new CloudflareClients({ accountId, apiToken }),
115
+ apiToken,
116
+ });
117
+ }
118
+
119
+ /** The interactive confirm prompt for a production environment. Names what is about to happen first. */
120
+ function confirmPrompt(env: string): () => Promise<string> {
121
+ return async () => {
122
+ const { isCancel, text } = await import("@clack/prompts");
123
+ const answer = await text({
124
+ message: `This creates Cloudflare resources in ${env}. Type "${provisionConfirmPhrase(env)}" to confirm:`,
125
+ });
126
+ return isCancel(answer) ? "" : answer;
127
+ };
128
+ }
129
+
130
+ /** Refuse a run with no credentials, naming the environment it was for. */
131
+ function requireProvisioners(account: CloudflareAccountSelection | null, target: string): ResourceProvisioners {
132
+ const provisioners = buildProvisioners(account);
133
+ if (provisioners) return provisioners;
134
+ throw new ValidationError({
135
+ message: "Cloudflare credentials are missing.",
136
+ action: `Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to provision ${target}.`,
137
+ });
138
+ }
139
+
140
+ /** Everything `pithy provision` reads off the command line. */
141
+ export interface ProvisionRunOptions {
142
+ /**
143
+ * The project root. Defaults to the working directory.
144
+ *
145
+ * A seam, and one the refusal gate needs: `commands/provision.test.ts` proves the mode is refused
146
+ * before anything is read by pointing this at a directory that is not a project at all, which a test
147
+ * that had to `chdir` could not do without racing every other suite in the pool.
148
+ */
149
+ projectDir?: string;
150
+ /** `--env <name>`: a declared environment. */
151
+ env?: string | undefined;
152
+ /** `--feature`: this branch's own environment. */
153
+ feature: boolean;
154
+ /** `--yes`. Required for a declared environment; never sufficient for production. */
155
+ yes: boolean;
156
+ /** `--confirm <phrase>`: the production phrase, for a headless run. */
157
+ confirm?: string | undefined;
158
+ /** `--seed`: also load fixtures. A feature environment is always seeded, so this adds nothing to it. */
159
+ seed: boolean;
160
+ /** `--json`. */
161
+ json: boolean;
162
+ }
163
+
164
+ /** One line per file written: what landed there, and what happens to it next. */
165
+ function describeConfigs(report: ProvisionReport): string[] {
166
+ return report.configs.map((config) => {
167
+ const what = config.ids === 0 ? config.path : `${config.ids} id${config.ids === 1 ? "" : "s"} into ${config.path}`;
168
+ // The whole point of the line: one flag decides whether these bytes are reviewed and kept, or thrown
169
+ // away and rebuilt. Saying which costs a sentence and saves someone committing a build artifact — or
170
+ // wondering why an id they were told to commit is not in `git status`.
171
+ const fate = report.committed
172
+ ? `Commit ${config.ids > 1 ? "them" : "it"}.`
173
+ : "Ignored, and rebuilt on the next run.";
174
+ return `Wrote ${what}. ${fate}`;
175
+ });
176
+ }
177
+
178
+ /**
179
+ * **What this command declares and cannot create**, and who can — which is not the same answer in both
180
+ * modes. See `provision/pendingSecrets.ts`, which holds the reasoning and the sentences.
181
+ */
182
+ function deferredSecrets(capabilities: Capability[], mode: ProvisionMode): PendingSecrets {
183
+ return pendingSecrets(workerSecretRegistry(capabilities) ?? {}, mode);
184
+ }
185
+
186
+ /** Write the report: one JSON line, or the human summary. */
187
+ function writeReport(
188
+ report: ProvisionReport,
189
+ options: { json: boolean; seeded: boolean; pending: PendingSecrets },
190
+ ): void {
191
+ if (options.json) {
192
+ process.stdout.write(
193
+ `${formatJsonLine({
194
+ command: "provision",
195
+ ...report,
196
+ pendingSecrets: options.pending.names,
197
+ pendingSecretsRemedy: options.pending.remedy,
198
+ })}\n`,
199
+ );
200
+ return;
201
+ }
202
+ for (const resource of report.resources) {
203
+ process.stdout.write(`${resource.name}: ${resource.created ? "created" : "exists"}.\n`);
204
+ }
205
+ for (const worker of report.workers) {
206
+ process.stdout.write(`${worker.worker} deploys as ${worker.name}.\n`);
207
+ }
208
+ for (const service of report.services) {
209
+ process.stdout.write(`${service.binding} bound to ${service.service}.\n`);
210
+ }
211
+ for (const secret of report.secretBindings) {
212
+ // Three states, and the middle one is new (#321): created by this run, already there, or waiting on
213
+ // a human. The value never appears — what was made and where it went is the whole useful report.
214
+ if (secret.minted) {
215
+ process.stdout.write(`${secret.binding} created. Reads ${secret.entry}.\n`);
216
+ } else if (secret.bound) {
217
+ process.stdout.write(`${secret.binding} reads ${secret.entry}.\n`);
218
+ } else {
219
+ process.stdout.write(
220
+ `${secret.binding} has no store entry yet. Create it with pithy secrets create ${secret.binding}.\n`,
221
+ );
222
+ }
223
+ }
224
+ for (const line of describeConfigs(report)) process.stdout.write(`${line}\n`);
225
+ process.stdout.write(`Provisioned ${report.env}. ${options.seeded ? "Migrated and seeded." : "Migrated."}\n`);
226
+ // Before `Done.`, because it is the part of the job this command did not do. See `pendingSecrets`.
227
+ for (const line of pendingSecretLines(options.pending)) process.stdout.write(`${line}\n`);
228
+ process.stdout.write(`${formatDone()}\n`);
229
+ }
230
+
231
+ /**
232
+ * `--env <name>`: an environment the project declares, whose ids are source.
233
+ *
234
+ * It takes the resolved {@link ProvisionMode} rather than a bare name, and hands it on to the deferred-
235
+ * secrets report. There is one producer of the mode — `requireProvisionMode`, in `runProvision` — so
236
+ * neither branch can report itself as the other, which is how `--feature` came to print `--env`'s
237
+ * remedy in the first place (#330).
238
+ */
239
+ async function provisionDeclared(
240
+ projectDir: string,
241
+ mode: Extract<ProvisionMode, { kind: "environment" }>,
242
+ options: ProvisionRunOptions,
243
+ ): Promise<void> {
244
+ const env = mode.env;
245
+ const config = await loadProject(projectDir);
246
+ // The declaration decides what may be provisioned. `--env live` on a project that never declared
247
+ // `live` is refused here, naming the set it does have — rather than creating `<project>-live-db`
248
+ // that nothing else in the CLI would ever look for again. It is also what closes `--env feature`:
249
+ // that name is a legal stanza key and an illegal declaration, so no project can admit it.
250
+ const environment = requireManagedEnvironment(env, loadProjectEnvironments(config));
251
+ const interactive = !options.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
252
+ await assertProvisionConfirmed({
253
+ env: environment,
254
+ yes: options.yes,
255
+ json: options.json,
256
+ ...(options.confirm !== undefined ? { confirmPhrase: options.confirm } : {}),
257
+ ...(interactive ? { prompt: confirmPrompt(environment) } : {}),
258
+ ...(config.seed?.productionEnvironments !== undefined
259
+ ? { productionEnvironments: config.seed.productionEnvironments }
260
+ : {}),
261
+ });
262
+
263
+ const account = loadProjectCloudflare(config) ?? null;
264
+ const provisioners = requireProvisioners(account, environment);
265
+ const capabilities = projectCapabilities(await resolveWorkers({ projectDir }));
266
+ // The scope carries both the names and the stanza. There is no second argument to disagree with.
267
+ const scope = environmentScope(requireProjectName(config), environment);
268
+ const store = buildStore(account);
269
+ const audit = await buildAudit(projectDir, capabilities, account);
270
+ const report = await provisionEnvironment({
271
+ projectDir,
272
+ scope,
273
+ capabilities,
274
+ provisioners,
275
+ ...(store
276
+ ? {
277
+ secretBindings: async (workerCapabilities) =>
278
+ secretsStoreBindings({
279
+ // A Worker composing no secrets capability declares no secrets, and gets no stanza.
280
+ registry: workerSecretRegistry(workerCapabilities) ?? {},
281
+ scope,
282
+ storeId: store.storeId,
283
+ exists: (name) => store.exists(name),
284
+ // A declared secret whose value is arbitrary is created here rather than printed as
285
+ // homework (#321). Absence is checked first, so an existing value is never replaced.
286
+ mint: storeSecretMinter({ store, environment: scope.stanza, audit }),
287
+ }),
288
+ }
289
+ : {}),
290
+ // Off unless asked. A declared environment already holds real rows; seeding one is `pithy seed`'s
291
+ // job, with its own gate, and it must not be something provisioning did on the way past.
292
+ seedData: options.seed,
293
+ audit,
294
+ });
295
+ writeReport(report, { json: options.json, seeded: options.seed, pending: deferredSecrets(capabilities, mode) });
296
+ }
297
+
298
+ /**
299
+ * `--feature`: this branch's own environment, whose ids are a build artifact.
300
+ *
301
+ * **No confirmation gate, and that is not an omission.** A feature environment is created per pull
302
+ * request and destroyed on merge; requiring the phrase that protects production would put it in every
303
+ * pipeline, which is exactly how a gate stops meaning anything. It also always seeds — a feature
304
+ * environment is created empty and useless without fixtures — so `--seed` has nothing to add to it.
305
+ */
306
+ async function provisionBranch(
307
+ projectDir: string,
308
+ mode: Extract<ProvisionMode, { kind: "feature" }>,
309
+ options: ProvisionRunOptions,
310
+ ): Promise<void> {
311
+ const { identity, capabilities } = await branchIdentity(projectDir);
312
+ const account = await projectCloudflareAccount(projectDir);
313
+ const provisioners = requireProvisioners(account, "a feature environment");
314
+ const store = buildStore(account);
315
+ const report = await provisionFeature({
316
+ projectDir,
317
+ capabilities,
318
+ ...(store ? { store } : {}),
319
+ identity,
320
+ provisioners,
321
+ audit: await buildAudit(projectDir, capabilities, account),
322
+ });
323
+ writeReport(report, { json: options.json, seeded: true, pending: deferredSecrets(capabilities, mode) });
324
+ }
325
+
326
+ /**
327
+ * The command body, exported so the mode gate can be tested against a directory that is not a project.
328
+ *
329
+ * Throws `PithyError`; the citty wrapper below is what reports and exits.
330
+ */
331
+ export async function runProvision(options: ProvisionRunOptions): Promise<void> {
332
+ // First, before the working directory is read, before a config is loaded, and before any Cloudflare
333
+ // client exists. A run that named no environment or two is a mistake in the command line, and it gets
334
+ // an answer about the command line.
335
+ const mode = requireProvisionMode(options);
336
+ const projectDir = options.projectDir ?? process.cwd();
337
+ if (mode.kind === "feature") return provisionBranch(projectDir, mode, options);
338
+ return provisionDeclared(projectDir, mode, options);
339
+ }
340
+
341
+ export default defineCommand({
342
+ meta: {
343
+ name: "provision",
344
+ description: "Create an environment's own Cloudflare resources, wire them into each Worker, then migrate",
345
+ },
346
+ args: {
347
+ env: { type: "string", description: "The declared environment to provision" },
348
+ feature: { type: "boolean", default: false, description: "Provision this branch's own environment instead" },
349
+ yes: { type: "boolean", default: false, description: "Confirm that this creates real Cloudflare resources" },
350
+ confirm: {
351
+ type: "string",
352
+ description: 'Unlock a production environment non-interactively: "yes, i really want to provision <env>"',
353
+ },
354
+ seed: { type: "boolean", default: false, description: "With --env: also load seed fixtures once the schema is up" },
355
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
356
+ },
357
+ run: ({ args }) =>
358
+ withErrorReporting(args.json, () =>
359
+ runProvision({
360
+ env: args.env,
361
+ feature: args.feature,
362
+ yes: args.yes,
363
+ confirm: args.confirm,
364
+ seed: args.seed,
365
+ json: args.json,
366
+ }),
367
+ ),
368
+ });
@@ -0,0 +1,151 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join, relative } from "node:path";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { defineCommand } from "citty";
7
+ import { defaultRemoveSteps, removeCapability } from "../capabilities/remove";
8
+ import { loadProject, projectCloudflareAccount, requireProjectName } from "../project/config";
9
+ import { envArg, requireEnvironment } from "../project/environment";
10
+ import { formatDone, withErrorReporting } from "../terminal/output";
11
+ import { buildAudit, targetWorker } from "./add";
12
+
13
+ /**
14
+ * `remove` is the deliberate exception to the agent-drivable / `--json` convention: it is destructive,
15
+ * so it is human-only. `--json` fast-fails here — before anything is read or changed.
16
+ */
17
+ export function rejectJson(json: boolean): void {
18
+ if (json) {
19
+ throw new ValidationError({
20
+ message: "pithy remove is a manual command. --json is not supported.",
21
+ action: "Run pithy remove <capability> at a terminal.",
22
+ });
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The `--drop` confirmation for an environment. `dev` asks a light yes/no; any non-dev env demands the
28
+ * exact typed phrase `drop <cap> from <env>` (the Cloudflare-dashboard delete pattern) — a mismatch or
29
+ * a cancel returns false and aborts with zero changes. There is no bypass flag.
30
+ */
31
+ export function dropConfirm(capability: string, env: string): () => Promise<boolean> {
32
+ return async () => {
33
+ const clack = await import("@clack/prompts");
34
+ if (env === "dev") {
35
+ const answer = await clack.confirm({ message: `Drop ${capability}'s tables from dev? This deletes data.` });
36
+ return !clack.isCancel(answer) && answer === true;
37
+ }
38
+ const phrase = `drop ${capability} from ${env}`;
39
+ const typed = await clack.text({ message: `This deletes ${env} data. Type "${phrase}" to confirm:` });
40
+ return !clack.isCancel(typed) && typed === phrase;
41
+ };
42
+ }
43
+
44
+ export default defineCommand({
45
+ meta: { name: "remove", description: "Remove a capability — the manual, interactive inverse of add" },
46
+ args: {
47
+ capability: { type: "positional", required: true, description: "Capability name, e.g. auth" },
48
+ worker: { type: "string", description: "Which worker to unwire it from (apps/<name>)" },
49
+ drop: {
50
+ type: "boolean",
51
+ default: false,
52
+ description: "Also roll back the capability's migrations (drops its tables)",
53
+ },
54
+ env: envArg("With --drop, the environment whose tables to drop"),
55
+ json: { type: "boolean", default: false, description: "Not supported — remove is manual-only" },
56
+ },
57
+ // Errors always render as terminal problem/action lines: `remove` has no machine-readable surface.
58
+ run: ({ args }) =>
59
+ withErrorReporting(false, async () => {
60
+ rejectJson(args.json);
61
+
62
+ const projectDir = process.cwd();
63
+ const env = requireEnvironment(args.env);
64
+
65
+ // Which Worker to unwire. `remove` is human-only, so the prompt is available whenever a TTY is.
66
+ const target = await targetWorker({
67
+ projectDir,
68
+ interactive: Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY),
69
+ ...(args.worker === undefined ? {} : { worker: args.worker }),
70
+ });
71
+ const capabilities = target.capabilities;
72
+
73
+ // `requireProjectName`, never `resolveProjectName`: a `--drop` reverses migrations against a live
74
+ // database, and the name is what that database's owner stamp is checked against. A guessed one
75
+ // (the alphabetically-first Worker, the directory basename) differs between checkouts, so it would
76
+ // either refuse this project's own database or claim another's. Resolved here, at the command edge,
77
+ // before anything is read or unwired — a nameless project is told to fix its config, not half-removed.
78
+ const project = requireProjectName(await loadProject(projectDir));
79
+ // Resolved once, at the command edge, and used for both the drop and the audit. A `--drop --env
80
+ // staging` reverses migrations against a live database; the account is what says whose (#234).
81
+ const account = await projectCloudflareAccount(projectDir);
82
+
83
+ const result = await removeCapability({
84
+ workerDir: target.dir,
85
+ capability: args.capability,
86
+ drop: args.drop ? { env, confirm: dropConfirm(args.capability, env) } : undefined,
87
+ steps: defaultRemoveSteps({
88
+ projectDir,
89
+ workerDir: target.dir,
90
+ loadCapabilities: async () => capabilities,
91
+ project,
92
+ account,
93
+ }),
94
+ // `--drop`'s env is the natural audit target when given; otherwise "dev", which is inert — a
95
+ // plain unwiring has no live environment, and the audit database is resolved from the project
96
+ // root, narrowed to the Worker being unwired.
97
+ audit: await buildAudit({
98
+ account,
99
+ projectDir,
100
+ worker: target.name,
101
+ env: args.drop ? env : "dev",
102
+ capabilities,
103
+ }),
104
+ });
105
+
106
+ if (!result.present) {
107
+ process.stdout.write(`${args.capability} is not present in ${target.name}. Nothing to remove.\n`);
108
+ return;
109
+ }
110
+ if (result.aborted) {
111
+ process.stdout.write("Aborted. Nothing changed.\n");
112
+ return;
113
+ }
114
+
115
+ process.stdout.write(`Removed ${args.capability} from ${target.name}.\n`);
116
+ if (result.dropped) {
117
+ const total = result.dropped.reduce((sum, run) => sum + run.results.length, 0);
118
+ process.stdout.write(`Dropped ${total} migration${total === 1 ? "" : "s"} from ${env}.\n`);
119
+ }
120
+ if (result.ejected) {
121
+ process.stdout.write(`Deleted ${relative(projectDir, join(target.dir, "capabilities", args.capability))}/.\n`);
122
+ } else if (result.packageManager) {
123
+ process.stdout.write(`Uninstalled @pithy-sh/${args.capability}.\n`);
124
+ } else if (result.keptLinked) {
125
+ // Nothing declared the package, so there is nothing to uninstall — and npm, asked anyway, prunes
126
+ // every linked sibling with it. Say what was left, so the link in node_modules is never a surprise.
127
+ process.stdout.write(`Kept @pithy-sh/${args.capability} — a linked checkout provides it.\n`);
128
+ } else if (result.keptFor.length > 0) {
129
+ // The wiring is gone from this Worker, but the package is one shared install — say so plainly,
130
+ // and name who still holds it, so the leftover dependency is never a surprise.
131
+ const holders = result.keptFor.join(", ");
132
+ const verb = result.keptFor.length === 1 ? "still wires it" : "still wire it";
133
+ process.stdout.write(`Kept @pithy-sh/${args.capability} installed — ${holders} ${verb}.\n`);
134
+ }
135
+ if (result.tablesRemain) {
136
+ // The down code is gone now, so there's no post-removal pithy command to reverse them — name
137
+ // the tables to drop by hand, and point at --drop for next time.
138
+ //
139
+ // Unless a sibling Worker still wires the capability: Workers sharing a binding name share one
140
+ // database, so those tables are live for the sibling. Telling someone to drop them by hand would
141
+ // be telling them to delete data another Worker is serving.
142
+ const shared = result.keptFor.length > 0;
143
+ process.stdout.write(
144
+ shared
145
+ ? `${args.capability}'s D1 tables were left in place — ${result.keptFor.join(", ")} still ${result.keptFor.length === 1 ? "wires" : "wire"} it and ${result.keptFor.length === 1 ? "is" : "are"} using them. Don't drop them by hand.\n`
146
+ : `${args.capability}'s D1 tables were left in place — your data is safe, and a later pithy add ${args.capability} reuses them. To drop them, remove the pithy_${args.capability}_* tables by hand (pass --drop to reverse them during removal).\n`,
147
+ );
148
+ }
149
+ process.stdout.write(`${formatDone()}\n`);
150
+ }),
151
+ });