@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,281 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { basename } from "node:path";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { isSourceEnvironment } from "../provision/featureConfig";
8
+ import {
9
+ type AppWorkflowPlan,
10
+ appOwnedWorkflows,
11
+ planAppWorkflows,
12
+ type WorkflowConfig,
13
+ type WorkflowStanza,
14
+ } from "./appWorkflows";
15
+ import { loadProject, loadProjectEnvironments, loadWorkerConfig, requireProjectName } from "./config";
16
+ import { discoverWorkers } from "./workers";
17
+ import { readOptionalWranglerConfig } from "./wrangler";
18
+
19
+ /**
20
+ * **What the app capability declares is what the environment's stanza binds.**
21
+ *
22
+ * That is the whole invariant, and until #267 nothing asked it. `reconcileAppWorkflows` derives the
23
+ * `workflows` table and `triggers.crons` from the `app` capability's `workflows` map and writes them into
24
+ * `wrangler.jsonc`; its only caller is `pithy worker sync`; and no check anywhere read the two halves back.
25
+ * The same structure as #264 one file over — a declaration in `pithy.config.ts`, a fact in
26
+ * `wrangler.jsonc`, one writer, no reader — and the same ending: an adopter who declares a job and never
27
+ * runs `sync` gets a green `pithy doctor`, a green `pithy deploy`, and a Worker that ships with no
28
+ * `workflows` entry and no `triggers.crons`.
29
+ *
30
+ * What that costs is worth naming, because two of the three failures are loud and the third is not:
31
+ *
32
+ * - **The cron never fires.** Nothing is wrong anywhere. No request fails, no log line appears, no probe
33
+ * goes red. The job simply does not run, and the first sign of it is whatever the job existed to
34
+ * prevent. This is the failure this reader exists for.
35
+ * - **`c.var.workflows.trigger("board/digest", …)` cannot reach a binding wrangler never wrote.** Loud,
36
+ * at least: `createBackend` derives a `workflow` binding spec from every registered job, so a
37
+ * non-`optional` one that is absent fails `validateBindings` on the Worker's first request.
38
+ * - **A binding that carries another environment's name** — the shape a copy-pasted stanza produces —
39
+ * starts instances of a Workflow deployed for somewhere else.
40
+ *
41
+ * ## The question is asked once, of the whole table
42
+ *
43
+ * Not "is every declared job bound?" plus "is every bound job declared?" plus "is every cron declared?".
44
+ * Those are three ways for one comparison to come out, and a gate written as a list of them is a gate
45
+ * that will one day be missing the fourth — which is exactly how #264 shipped, asking what `workers_dev`
46
+ * was set to instead of what serves the host. So the declaration is reduced to the table it implies
47
+ * (through {@link planAppWorkflows}, the same function the writer plans with, so the two cannot disagree
48
+ * about what "declared" means), the stanza's own app-owned table is read back beside it (through
49
+ * {@link appOwnedWorkflows}, the same rule the writer replaces by), and the two are compared whole. A
50
+ * missing binding, a stale binding, a stale cron and a wrong name are all one fault with one remedy,
51
+ * and so is anything else that can make the two differ.
52
+ *
53
+ * Order is deliberately not part of it. Cloudflare reads a table, the array's order carries no meaning,
54
+ * and a fault against a hand-ordered stanza that binds exactly the right things would be noise.
55
+ *
56
+ * ## What it does not claim
57
+ *
58
+ * A Worker whose `pithy.config.ts` will not import (dependencies not installed, most often) makes no
59
+ * claim at all — the #264 rule, for the same reason: "this declares no jobs" is a negative finding, and a
60
+ * config nobody could open is precisely the one that might have declared some. A Worker with no `app`
61
+ * capability makes none either: there is no declaration to compare against, and `pithy worker sync`
62
+ * writes nothing for it, so a fault there would name a command that cannot answer it.
63
+ *
64
+ * Files only, offline, no account call — the standard `originDrift` and `unprovisionedBindings` are held
65
+ * to, and the reason this can gate a deploy without costing it a round trip.
66
+ */
67
+
68
+ /** Which of the two ways a declaration and a stanza fail to be the same thing. Two remedies. */
69
+ export type WorkflowFault =
70
+ /** The stanza does not bind what the app declares — in either direction. `pithy worker sync` settles it. */
71
+ | "unsynced-stanza"
72
+ /** The declaration cannot be reduced to a stanza at all, so no command could write one. */
73
+ | "unwritable-declaration";
74
+
75
+ /** One Worker-and-environment whose declaration and stanza are not the same table. */
76
+ export interface WorkflowDrift {
77
+ /** The Worker's `apps/<name>` directory. */
78
+ worker: string;
79
+ /** The environment. */
80
+ env: string;
81
+ /** Which fault this is. */
82
+ fault: WorkflowFault;
83
+ /** What the app capability declares for this environment. Empty on `unwritable-declaration`. */
84
+ declared: AppWorkflowPlan;
85
+ /** What this environment's stanza binds for this Worker's own script. Evidence, never a secret. */
86
+ bound: AppWorkflowPlan;
87
+ /**
88
+ * On `unwritable-declaration` alone: why the declaration could not be reduced to a stanza.
89
+ *
90
+ * Carried rather than re-derived, because the refusal is core's and its sentence names the field —
91
+ * a job with no `className` has no class for wrangler to instantiate, and only `hostWorkflowsFor`
92
+ * knows that. Restating it here would be a second, staler copy of the same rule.
93
+ */
94
+ reason?: string;
95
+ }
96
+
97
+ /** What `doctor` learned. Listed positively, so an inconclusive read never gates CI. */
98
+ export type WorkflowsState =
99
+ /** Every declared environment binds exactly what its Worker's app capability declares. */
100
+ | "ok"
101
+ /** The root config would not load or names no project, so no Workflow name could be composed. */
102
+ | "could-not-check"
103
+ /** A declaration and a stanza disagree. Established from local files alone. */
104
+ | "drifted";
105
+
106
+ /** What `doctor` reports about this project's app-declared Workflows. */
107
+ export interface WorkflowsCheck {
108
+ state: WorkflowsState;
109
+ drift: WorkflowDrift[];
110
+ }
111
+
112
+ /** The empty table — what an environment declares when its job cannot be named, and what a bare stanza binds. */
113
+ const NOTHING: AppWorkflowPlan = { workflows: [], crons: [] };
114
+
115
+ /**
116
+ * One table, in a form two of them can be compared by.
117
+ *
118
+ * Every field of every entry, because presence is not the question: a binding called `DIGEST` pointing
119
+ * at `replay-staging-board-digest` in `env.prod` is bound, and bound to the wrong Workflow. Sorted,
120
+ * because the array's order is not part of the invariant.
121
+ */
122
+ function canonical(plan: AppWorkflowPlan): string {
123
+ return JSON.stringify({
124
+ workflows: plan.workflows.map((entry) => [entry.binding, entry.name, entry.class_name]).sort(),
125
+ crons: [...plan.crons].sort(),
126
+ });
127
+ }
128
+
129
+ /** What one environment's stanza binds for this Worker's own script — the right-hand side of the comparison. */
130
+ function boundBy(stanza: WorkflowStanza | undefined): AppWorkflowPlan {
131
+ return { workflows: appOwnedWorkflows(stanza), crons: [...(stanza?.triggers?.crons ?? [])] };
132
+ }
133
+
134
+ /**
135
+ * One Worker's app capability, or `undefined` when there is no declaration to compare against.
136
+ *
137
+ * The two cases collapse deliberately. A config that will not import declares nothing *knowable*, and a
138
+ * Worker with no `app` block declares nothing *at all* — and in both, the honest output is silence
139
+ * rather than a fault naming a command that would report, correctly and uselessly, that it wrote nothing.
140
+ */
141
+ async function workerApp(workerDir: string): Promise<Capability | undefined> {
142
+ try {
143
+ return (await loadWorkerConfig(workerDir)).app;
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Every Worker-and-environment in `environments` whose declaration and stanza are not the same table, in
151
+ * worker then environment order.
152
+ *
153
+ * `project` is passed in and never guessed: a Workflow name is account-scoped and leads with the project,
154
+ * so a guessed one would compare the stanza against a name belonging to somebody else's project. It is
155
+ * `requireProjectName`'s answer, exactly as `reconcileAppWorkflows` takes it.
156
+ */
157
+ export async function workflowDrift(
158
+ projectDir: string,
159
+ project: string,
160
+ environments: readonly string[],
161
+ ): Promise<WorkflowDrift[]> {
162
+ const drift: WorkflowDrift[] = [];
163
+ for (const target of await discoverWorkers(projectDir)) {
164
+ if (target.hasWrangler === false) continue;
165
+ const config = (await readOptionalWranglerConfig(target.dir).catch(() => null)) as WorkflowConfig | null;
166
+ if (!config) continue;
167
+ const app = await workerApp(target.dir);
168
+ if (!app) continue;
169
+ const worker = basename(target.dir);
170
+ for (const env of environments) {
171
+ let declared: AppWorkflowPlan;
172
+ try {
173
+ declared = planAppWorkflows(app, { project, env });
174
+ } catch (error) {
175
+ // Reported, never rethrown. `planAppWorkflows` refuses a job it cannot name, and a check that
176
+ // let that escape would take doctor's whole block down to a `catch` and say nothing at all —
177
+ // which is the failure mode this file exists to remove, reintroduced one level up.
178
+ drift.push({
179
+ worker,
180
+ env,
181
+ fault: "unwritable-declaration",
182
+ declared: NOTHING,
183
+ bound: NOTHING,
184
+ reason: messageOf(error),
185
+ });
186
+ continue;
187
+ }
188
+ const bound = boundBy(config.env?.[env]);
189
+ if (canonical(declared) !== canonical(bound)) {
190
+ drift.push({ worker, env, fault: "unsynced-stanza", declared, bound });
191
+ }
192
+ }
193
+ }
194
+ return drift;
195
+ }
196
+
197
+ /** One table as a sentence fragment: what is bound, or `nothing`. Both sides of every report read this way. */
198
+ function summarize(plan: AppWorkflowPlan): string {
199
+ const parts = [
200
+ ...plan.workflows.map((entry) => `${entry.binding} → ${entry.name}`),
201
+ ...plan.crons.map((cron) => `cron ${cron}`),
202
+ ];
203
+ return parts.length === 0 ? "nothing" : parts.join(", ");
204
+ }
205
+
206
+ /**
207
+ * One drift, as the sentence that fits it — and the sentence is the comparison, both sides of it.
208
+ *
209
+ * Naming only the half that is missing would leave the reader opening two files to learn what was
210
+ * compared against what, and it would have nothing to say at all about the direction where the stanza
211
+ * carries more than the declaration.
212
+ */
213
+ export function describeWorkflowDrift(drift: WorkflowDrift): string {
214
+ if (drift.fault === "unwritable-declaration") {
215
+ return `${drift.worker} declares a job nothing can bind in ${drift.env}: ${drift.reason} Fix workflows in the Worker's pithy.config.ts — pithy worker sync would refuse it the same way.`;
216
+ }
217
+ return `${drift.worker} declares ${summarize(drift.declared)} for ${drift.env}, and env.${drift.env} binds ${summarize(drift.bound)}. Run pithy worker sync to write the declaration into wrangler.jsonc.`;
218
+ }
219
+
220
+ /** The throw-site context behind each fault — why this one is worth a refusal. */
221
+ function workflowDetail(fault: WorkflowFault): string {
222
+ return fault === "unwritable-declaration"
223
+ ? "hostWorkflowsFor refuses a job it cannot name, so there is no stanza any command could write for this environment."
224
+ : "A declared Workflow and the binding that runs it are two halves of one fact in two files, and only pithy worker sync writes the second — so a cron nothing bound never fires, and nothing anywhere reports it.";
225
+ }
226
+
227
+ /**
228
+ * Refuse to deploy into an environment that does not bind what its Workers declare.
229
+ *
230
+ * The same moment, and the same reasoning, as `assertOriginsDeclared` beside it: deploy already knows the
231
+ * environment and already has the config, and this is the last point at which the mistake is still
232
+ * hypothetical. It costs no account call.
233
+ *
234
+ * **A feature environment is exempt, and by the same rule.** Its stanza is a generated build artifact
235
+ * under `.wrangler/`, written by provisioning from the tracked file rather than by `pithy worker sync` —
236
+ * so a fault here would refuse every feature deploy and name a command that does not write that file.
237
+ * `isSourceEnvironment` is the predicate the config-path resolver uses, so the two cannot disagree about
238
+ * which environments keep their config in source.
239
+ *
240
+ * A project whose root config will not load or names nothing establishes nothing, and a deploy is not
241
+ * refused on nothing — the same evidence standard every gate here is held to.
242
+ */
243
+ export async function assertWorkflowsBound(projectDir: string, env: string): Promise<void> {
244
+ if (!isSourceEnvironment(env)) return;
245
+ const project = await loadProject(projectDir)
246
+ .then(requireProjectName)
247
+ .catch(() => null);
248
+ if (project === null) return;
249
+ const drift = await workflowDrift(projectDir, project, [env]);
250
+ if (drift.length === 0) return;
251
+ const first = drift[0] as WorkflowDrift;
252
+ throw new ValidationError({
253
+ message: `${env} does not bind what it declares: ${drift.map((entry) => entry.worker).join(", ")}.`,
254
+ action: drift.map(describeWorkflowDrift).join(" "),
255
+ detail: workflowDetail(first.fault),
256
+ });
257
+ }
258
+
259
+ /**
260
+ * The same question asked of every environment the project declares, for `pithy doctor`.
261
+ *
262
+ * `could-not-check` when the root config will not load or names no project: a Workflow name leads with
263
+ * the project, so without one there is nothing to compare a stanza against — and the `Project:` block
264
+ * already owns saying that a config would not read.
265
+ *
266
+ * `dev` is not among the environments walked, and needs no special case: it is never declared, and the
267
+ * declared set is what a deploy ever targets.
268
+ */
269
+ export async function checkWorkflows(projectDir: string): Promise<WorkflowsCheck> {
270
+ let project: string;
271
+ let environments: readonly string[];
272
+ try {
273
+ const config = await loadProject(projectDir);
274
+ project = requireProjectName(config);
275
+ environments = loadProjectEnvironments(config);
276
+ } catch {
277
+ return { state: "could-not-check", drift: [] };
278
+ }
279
+ const drift = await workflowDrift(projectDir, project, environments);
280
+ return { state: drift.length > 0 ? "drifted" : "ok", drift };
281
+ }
@@ -0,0 +1,168 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { join } from "node:path";
6
+ import { ConflictError, InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { parse } from "comment-json";
8
+ import { writeJsonc } from "./jsonc";
9
+ import { readOptionalFile } from "./readOptionalFile";
10
+
11
+ /** The slice of `wrangler.jsonc` the per-environment var helpers read and write. */
12
+ export interface WranglerEnvVars {
13
+ env?: Record<string, { vars?: Record<string, string> } | undefined>;
14
+ }
15
+
16
+ /**
17
+ * The parsed `wrangler.jsonc`, comments preserved (comment-json), or `null` when there is **no file**.
18
+ *
19
+ * Nineteen modules read a Worker's config through this wrapper, and until #204 it read the bytes with a
20
+ * bare `readFile`. That put every one of those reads outside the ENOENT gate: the gate recognizes the leaf
21
+ * calls that hand back a file's contents, so a read behind a wrapper is one it cannot see, and
22
+ * `envInventory.ts` was left spelling out the errno branch for itself — correct, and invisible. The
23
+ * decision lives in {@link readOptionalFile} now, which is what puts this wrapper inside the rule.
24
+ *
25
+ * Absent is `ENOENT` and nothing else. A `wrangler.jsonc` that is there and will not open is a refusal
26
+ * naming it, never a Worker quietly reported as having no configuration.
27
+ */
28
+ export async function readOptionalWranglerConfig(projectDir: string): Promise<unknown> {
29
+ const path = join(projectDir, "wrangler.jsonc");
30
+ const raw = await readOptionalFile(path, {
31
+ unreadable: ({ code, cause }) =>
32
+ new ConflictError(
33
+ {
34
+ message: `Can't read ${path}.`,
35
+ action: "Fix the file's permissions, or move it aside, and run the command again.",
36
+ detail: `${code ?? "unknown error"} while reading ${path}`,
37
+ },
38
+ { cause },
39
+ ),
40
+ });
41
+ return raw === null ? null : parse(raw);
42
+ }
43
+
44
+ /**
45
+ * The same read, for the callers that have already established the Worker has a config — most of them.
46
+ * A directory with no `wrangler.jsonc` is a `PithyError` naming the file rather than node's own `ENOENT`
47
+ * escaping into a command's output. Caller casts the shape.
48
+ */
49
+ export async function readWranglerConfig(projectDir: string): Promise<unknown> {
50
+ const config = await readOptionalWranglerConfig(projectDir);
51
+ if (config === null) {
52
+ throw new NotFoundError({
53
+ message: `No wrangler.jsonc at ${join(projectDir, "wrangler.jsonc")}.`,
54
+ action: "Every worker lives in apps/<name> with its own wrangler.jsonc. Run pithy worker list to see them.",
55
+ });
56
+ }
57
+ return config;
58
+ }
59
+
60
+ /**
61
+ * The module a Worker's `main` names, absolute — the entry every `class_name` in that config resolves
62
+ * against, and the file `pithy add` writes a Durable Object's export into (#428). `null` when the config
63
+ * names none.
64
+ *
65
+ * Read from the config rather than assumed to be `src/index.ts`: `main` is wrangler's own answer to
66
+ * "which module is this Worker", the adopter may move it, and a Worker carrying a front end has one
67
+ * written by the Vite plugin. Guessing would mean writing an export into a file nothing bundles.
68
+ *
69
+ * A missing `main` is answered as a value rather than a throw, because the two callers mean different
70
+ * things by it: `add` is about to wire a class into a Worker that cannot say which module it is, and
71
+ * refuses by name; `remove` is unwiring one and has nothing to take out, so it moves on rather than
72
+ * stranding a capability half-removed.
73
+ */
74
+ export async function workerEntryPath(workerDir: string): Promise<string | null> {
75
+ const config = (await readWranglerConfig(workerDir)) as { main?: unknown };
76
+ if (typeof config.main !== "string" || config.main === "") return null;
77
+ return join(workerDir, config.main);
78
+ }
79
+
80
+ /**
81
+ * Write `wrangler.jsonc` back comment-preserving, printed the way the Biome `pithy init` scaffolds would
82
+ * print it and shaped like the bytes already there — see {@link writeJsonc}. This wrote `stringify`'s
83
+ * fully expanded output until #249, so every command that edits a Worker's config left a file the
84
+ * adopter's own commit hook rejected, and buried a two-line change in a whole-file reformat.
85
+ */
86
+ export async function writeWranglerConfig(projectDir: string, config: unknown): Promise<void> {
87
+ await writeJsonc(join(projectDir, "wrangler.jsonc"), config);
88
+ }
89
+
90
+ export interface WranglerOptions {
91
+ /**
92
+ * Stream wrangler's output straight to the terminal. Off by default: the output is captured and
93
+ * surfaced **only on failure** — quiet on success, the error when there is one. That's pithy.
94
+ */
95
+ passthrough?: boolean;
96
+ /** Working directory for the command. */
97
+ cwd?: string;
98
+ /**
99
+ * Override the executable to spawn (with `args` passed straight through). Tests set this to a
100
+ * stand-in. When omitted, wrangler runs via `bun x wrangler` so the workspace devDependency
101
+ * resolves — `pithy` does not assume a globally-installed wrangler.
102
+ */
103
+ bin?: string;
104
+ /**
105
+ * Extra env vars merged onto the child process. Provisioning passes `CLOUDFLARE_API_TOKEN`
106
+ * (from the `.dev.vars` token) so wrangler authenticates without a separate `wrangler login` —
107
+ * `.dev.vars` stays the single source of credentials.
108
+ */
109
+ env?: Record<string, string>;
110
+ }
111
+
112
+ /**
113
+ * Run a wrangler command. We don't reimplement wrangler — `pithy` shells out to it (deploy, D1, …)
114
+ * and owns only the output discipline: by default it stays quiet, capturing stdout/stderr and
115
+ * raising them as the error `detail` if wrangler fails; with `passthrough`, wrangler's output streams
116
+ * through directly. A non-zero exit (or a missing binary) becomes a `PithyError`.
117
+ *
118
+ * On success it resolves with the captured `stdout`/`stderr` — empty strings in `passthrough` mode,
119
+ * where nothing is captured — so callers that need wrangler's output (e.g. `deploy` scraping the
120
+ * version id and url) can read it without giving up the quiet-on-success default.
121
+ *
122
+ * Wrangler is a workspace devDependency, not a global, so it runs through `bun x wrangler` — bun
123
+ * resolves the local install from the `cwd`. Tests override `bin` to spawn a stand-in directly.
124
+ */
125
+ export async function runWrangler(
126
+ args: string[],
127
+ options: WranglerOptions = {},
128
+ ): Promise<{ stdout: string; stderr: string }> {
129
+ const command = options.bin ?? "bun";
130
+ const commandArgs = options.bin ? args : ["x", "wrangler", ...args];
131
+ const label = options.bin ?? "wrangler";
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn(command, commandArgs, {
134
+ cwd: options.cwd,
135
+ stdio: options.passthrough ? "inherit" : ["ignore", "pipe", "pipe"],
136
+ env: options.env ? { ...process.env, ...options.env } : process.env,
137
+ });
138
+
139
+ let stdout = "";
140
+ let stderr = "";
141
+ child.stdout?.on("data", (chunk: Buffer) => {
142
+ stdout += chunk.toString();
143
+ });
144
+ child.stderr?.on("data", (chunk: Buffer) => {
145
+ stderr += chunk.toString();
146
+ });
147
+
148
+ child.on("error", (cause) => {
149
+ reject(
150
+ new InternalError({
151
+ message: `Could not run ${label}.`,
152
+ action: `Is ${label} installed and on PATH?`,
153
+ detail: cause.message,
154
+ }),
155
+ );
156
+ });
157
+
158
+ child.on("close", (code) => {
159
+ if (code === 0) {
160
+ resolve({ stdout, stderr });
161
+ return;
162
+ }
163
+ // Surface the captured output (the errors) even in quiet mode; in passthrough it already streamed.
164
+ const captured = options.passthrough ? "" : `\n${(stderr || stdout).trim()}`;
165
+ reject(new InternalError({ message: `${label} ${args[0] ?? ""} failed.`, detail: `exit ${code}${captured}` }));
166
+ });
167
+ });
168
+ }
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { isProductionEnv } from "../seed/safety";
6
+
7
+ /**
8
+ * **What stands between `pithy provision` and production.**
9
+ *
10
+ * Provisioning creates real account resources and runs migrations against them. Everything else in the
11
+ * kit that reaches production behind one flag is gated the same way, and for the same reason `--redo`
12
+ * is: `--yes` means "yes, this is not dev", and it is the flag every CI job already passes. It cannot
13
+ * also be what authorizes a first write against the environment paying customers are on.
14
+ *
15
+ * So production takes an exact phrase, **and the phrase names its environment** — a phrase typed for
16
+ * `staging` cannot be pasted into a command targeting `prod`, which one fixed sentence would allow. Who
17
+ * counts as production is `isProductionEnv`'s answer, the same one `pithy seed` uses, so a project whose
18
+ * production environment is called `live` declares it once in `seed.productionEnvironments` and is
19
+ * protected everywhere rather than in one command.
20
+ */
21
+
22
+ /** The exact phrase that unlocks provisioning one environment. Compared case-insensitively after trimming. */
23
+ export function provisionConfirmPhrase(env: string): string {
24
+ return `yes, i really want to provision ${env.trim().toLowerCase()}`;
25
+ }
26
+
27
+ /** Inputs to the provisioning gate. */
28
+ export interface ConfirmProvisionOptions {
29
+ /** The environment being provisioned. */
30
+ env: string;
31
+ /** The `--yes` flag. Required for every environment; never sufficient for production. */
32
+ yes: boolean;
33
+ /** Non-interactive mode (`--json`, or any headless run). No prompt is shown; the phrase must arrive by flag. */
34
+ json: boolean;
35
+ /** The `--confirm` flag value. Authoritative wherever present, so CI never has to answer a prompt. */
36
+ confirmPhrase?: string;
37
+ /** Interactive confirm seam: ask the operator for the phrase. Never called under `--json`. */
38
+ prompt?: () => Promise<string>;
39
+ /** The names this project classifies as production (`seed.productionEnvironments`), plus the built-ins. */
40
+ productionEnvironments?: readonly string[];
41
+ }
42
+
43
+ /**
44
+ * Enforce the gate. Resolves when the run is authorized, throws a `ValidationError` otherwise.
45
+ *
46
+ * - Any environment → requires `--yes`. Provisioning is never the accidental result of a bare command.
47
+ * - Production → requires `--yes` **and** the exact {@link provisionConfirmPhrase}, from `--confirm` or,
48
+ * interactively, from the prompt. `--json` forbids the prompt, so a headless production provision
49
+ * happens only when a human wrote the phrase into the pipeline.
50
+ */
51
+ export async function assertProvisionConfirmed(options: ConfirmProvisionOptions): Promise<void> {
52
+ if (!options.yes) {
53
+ throw new ValidationError({
54
+ message: `Provisioning ${options.env} creates real Cloudflare resources.`,
55
+ action: `Re-run with --yes to provision ${options.env}.`,
56
+ });
57
+ }
58
+
59
+ if (!isProductionEnv(options.env, options.productionEnvironments)) return;
60
+
61
+ const expected = provisionConfirmPhrase(options.env);
62
+ const matches = (input: string | undefined): boolean =>
63
+ input !== undefined && input.trim().toLowerCase() === expected;
64
+
65
+ // The flag is authoritative wherever present (CI or interactive).
66
+ if (options.confirmPhrase !== undefined) {
67
+ if (matches(options.confirmPhrase)) return;
68
+ throw new ValidationError({
69
+ message: `That is not the confirmation phrase for provisioning ${options.env}.`,
70
+ action: `Pass --confirm "${expected}".`,
71
+ });
72
+ }
73
+
74
+ if (!options.json && options.prompt) {
75
+ if (matches(await options.prompt())) return;
76
+ throw new ValidationError({
77
+ message: `Provisioning ${options.env} was not confirmed.`,
78
+ action: `Type the exact phrase, or pass --confirm "${expected}".`,
79
+ });
80
+ }
81
+
82
+ throw new ValidationError({
83
+ message: `${options.env} is a production environment.`,
84
+ action: `Pass --confirm "${expected}".`,
85
+ });
86
+ }