@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,184 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
6
+ import { hostWorkflowsFor } from "@pithy-sh/core/src/workflow/host";
7
+ import type { WorkflowHostNameParts } from "@pithy-sh/core/src/workflow/naming";
8
+ import type { WorkflowRegistry } from "@pithy-sh/core/src/workflow/spec";
9
+ import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
10
+
11
+ /**
12
+ * The bindings a capability's **provisioner** writes into the app's `wrangler.jsonc` — the ones
13
+ * `pithy add` deliberately cannot write.
14
+ *
15
+ * Wrangler validates its config before it does anything else, and two binding kinds carry required
16
+ * fields whose values only exist after provisioning: a `vectorize` entry must name an `index_name`,
17
+ * and a `workflows` entry must carry a `name` and a `class_name`. `pithy add` is offline — it knows
18
+ * neither the provisioned index name nor the per-environment Workflow script name — so an `add` that
19
+ * emitted a partial entry would leave the adopter with a `wrangler.jsonc` that fails to load. It
20
+ * emits nothing instead, and this module completes the entry once `pithy <capability> provision` has
21
+ * stood the resources up. `capabilities/add.ts` states the same contract from the other side.
22
+ *
23
+ * `dev` writes the top-level stanza and every other environment writes its own `env.<name>` — the same
24
+ * rule `commands/vector.ts` reads the app database id by and writes `VECTOR_PROVISIONED` by. Wrangler
25
+ * does not inherit bindings into a named environment, and every name written here is
26
+ * environment-scoped anyway. Upsert by binding name, comment-preserving, idempotent: re-provisioning
27
+ * rewrites the same entries in place.
28
+ */
29
+
30
+ /**
31
+ * One `workflows` entry, complete. `binding` and `class_name` are code references; `name` is the
32
+ * deployed Workflow and `script_name` the host Worker that runs it — both environment-scoped, which
33
+ * is exactly why they arrive here rather than at add time.
34
+ */
35
+ export interface AppWorkflowBinding {
36
+ /** The binding name the Worker env exposes, e.g. `STORAGE_SWEEP`. */
37
+ binding: string;
38
+ /** The deployed Workflow name, `<project>-<env>-<capability>-<job>`. */
39
+ name: string;
40
+ /** The exported `WorkflowEntrypoint` subclass that runs the job. */
41
+ class_name: string;
42
+ /** The host Worker the class lives in, `<project>-<env>-<capability>` — this is a cross-script binding. */
43
+ script_name: string;
44
+ }
45
+
46
+ /** One `vectorize` entry, complete. `index_name` is the provisioned index, `<project>-<env>-vector-<index>`. */
47
+ export interface AppVectorizeBinding {
48
+ /** The binding name the Worker env exposes, e.g. `VECTORIZE`. */
49
+ binding: string;
50
+ /** The provisioned Vectorize index this binding addresses. */
51
+ index_name: string;
52
+ /** Reach the real index in local dev. Vectorize has no local emulation, so this is always true. */
53
+ remote?: boolean;
54
+ }
55
+
56
+ /** The provisioner-owned slice of one `env.<env>` stanza. */
57
+ interface AppBindingStanza {
58
+ workflows?: AppWorkflowBinding[];
59
+ vectorize?: AppVectorizeBinding[];
60
+ }
61
+
62
+ /** The bindings one provisioning run wrote for one environment. Either list may be omitted. */
63
+ export interface AppBindings {
64
+ /** Complete `workflows` entries, one per hosted job. */
65
+ workflows?: readonly AppWorkflowBinding[];
66
+ /** Complete `vectorize` entries, one per configured index. */
67
+ vectorize?: readonly AppVectorizeBinding[];
68
+ }
69
+
70
+ /**
71
+ * Upsert entries into a binding array **in place**. comment-json stores an array's comments as
72
+ * symbol-keyed properties on the array object itself, so replacing the array with a fresh one would
73
+ * silently delete the adopter's notes — the rule `capabilities/remove.ts` and
74
+ * `feature/wranglerEnv.ts` both document.
75
+ */
76
+ function upsertByBinding<Entry extends { binding: string }>(entries: Entry[], incoming: readonly Entry[]): void {
77
+ for (const entry of incoming) {
78
+ const index = entries.findIndex((candidate) => candidate.binding === entry.binding);
79
+ if (index === -1) entries.push(entry);
80
+ else entries[index] = entry;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The fields wrangler's config validator **requires**, per binding kind. An entry missing one of these
86
+ * does not degrade — it fails validation, and `wrangler dev` and `wrangler deploy` both refuse to run.
87
+ * Stated here once, so the writer and its tests check the same list.
88
+ */
89
+ const REQUIRED_FIELDS: Record<"workflows" | "vectorize", readonly string[]> = {
90
+ workflows: ["binding", "name", "class_name"],
91
+ vectorize: ["binding", "index_name"],
92
+ };
93
+
94
+ /**
95
+ * Every `workflows`/`vectorize` entry in one stanza that wrangler would reject, described. Empty means
96
+ * the stanza loads. Exported because "does this config still load" is the property the writer promises,
97
+ * and a promise worth making is worth asserting.
98
+ */
99
+ export function incompleteBindings(stanza: unknown): string[] {
100
+ if (typeof stanza !== "object" || stanza === null) return [];
101
+ const problems: string[] = [];
102
+ for (const [key, required] of Object.entries(REQUIRED_FIELDS)) {
103
+ const entries = (stanza as Record<string, unknown>)[key];
104
+ if (!Array.isArray(entries)) continue;
105
+ entries.forEach((entry: unknown, index: number) => {
106
+ const record = typeof entry === "object" && entry !== null ? (entry as Record<string, unknown>) : {};
107
+ const missing = required.filter((field) => typeof record[field] !== "string" || record[field] === "");
108
+ if (missing.length > 0) {
109
+ const named = typeof record.binding === "string" ? record.binding : `#${index}`;
110
+ problems.push(`${key}[${named}] is missing ${missing.join(", ")}`);
111
+ }
112
+ });
113
+ }
114
+ return problems;
115
+ }
116
+
117
+ /**
118
+ * Write a capability's provisioned bindings into the project's `wrangler.jsonc`, for one environment.
119
+ *
120
+ * Every entry written here is complete by construction — the types demand each field wrangler
121
+ * requires — and the stanza is checked before the file is written, so a `wrangler.jsonc` this function
122
+ * touched always loads. Idempotent and comment-preserving.
123
+ */
124
+ export async function applyAppBindings(projectDir: string, env: string, bindings: AppBindings): Promise<void> {
125
+ if (!bindings.workflows?.length && !bindings.vectorize?.length) return;
126
+
127
+ const config = (await readWranglerConfig(projectDir)) as AppBindingStanza & {
128
+ env?: Record<string, AppBindingStanza | undefined>;
129
+ };
130
+
131
+ let stanza: AppBindingStanza = config;
132
+ if (env !== "dev") {
133
+ config.env ??= {};
134
+ stanza = config.env[env] ?? {};
135
+ config.env[env] = stanza;
136
+ }
137
+
138
+ if (bindings.workflows?.length) {
139
+ stanza.workflows ??= [];
140
+ upsertByBinding(stanza.workflows, bindings.workflows);
141
+ }
142
+ if (bindings.vectorize?.length) {
143
+ stanza.vectorize ??= [];
144
+ upsertByBinding(stanza.vectorize, bindings.vectorize);
145
+ }
146
+
147
+ // Never write a config wrangler will not load. A hand-edited entry that lost a field lands here too,
148
+ // which is the right place to hear about it — before the next deploy.
149
+ const problems = incompleteBindings(stanza);
150
+ if (problems.length > 0) {
151
+ throw new InternalError({
152
+ message: "wrangler.jsonc would not load with these bindings.",
153
+ action: `Fix the ${env} bindings in wrangler.jsonc by hand, then re-run provision.`,
154
+ detail: problems.join("; "),
155
+ });
156
+ }
157
+
158
+ await writeWranglerConfig(projectDir, config);
159
+ }
160
+
161
+ /**
162
+ * The complete `workflows` entries for one capability in one environment, derived from its own
163
+ * registry. Thin over core's {@link hostWorkflowsFor}, which already computes the deployed Workflow
164
+ * name from the spec — the app's entry is the host's plus `script_name`, because the class lives in
165
+ * the host Worker rather than the app's own script.
166
+ *
167
+ * Every job the capability owns, which is right when the app dispatches all of them (storage, vector).
168
+ * A capability whose host self-fires a job the app must never bind — `@pithy-sh/email`'s scheduler —
169
+ * filters the result against its manifest's `requiredBindings` before writing.
170
+ *
171
+ * Takes the same `{ project, capability, env }` object the naming seam does. `project` comes from
172
+ * `requireProjectName`, never `resolveProjectName`: these two names must match the ones the capability's
173
+ * provisioner deployed under byte for byte, and a guessed project would write a `script_name` pointing
174
+ * at a Worker that does not exist — a config that loads and a dispatch that fails at runtime.
175
+ */
176
+ export function appWorkflowBindings(registry: WorkflowRegistry, parts: WorkflowHostNameParts): AppWorkflowBinding[] {
177
+ // Through the facade, so the host script is held to a Worker's 63 rather than a Workflow's 64 — the
178
+ // one-character gap is real, and it is the one that survives an adopter enabling workers.dev.
179
+ const scriptName = resourceNames(parts.project).env(parts.env).worker(parts.capability);
180
+ return hostWorkflowsFor(registry, parts).workflows.map((entry) => ({
181
+ ...entry,
182
+ script_name: scriptName,
183
+ }));
184
+ }
@@ -0,0 +1,266 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { hostWorkflowsFor } from "@pithy-sh/core/src/workflow/host";
7
+ import { composeWorkflows } from "@pithy-sh/core/src/workflow/register";
8
+ import { stringify } from "comment-json";
9
+ import { incompleteBindings } from "./appBindings";
10
+ import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
11
+
12
+ /**
13
+ * Workflows the adopter's **own app capability** declares, reconciled into that Worker's `wrangler.jsonc`.
14
+ *
15
+ * A library capability's Workflows already have a path: `pithy <capability> provision` deploys the host
16
+ * Worker and `project/appBindings.ts` writes the cross-script binding. Nothing did it for the app's own,
17
+ * so the `workflows` array, `triggers.crons`, and the per-environment repetition of both were hand-written
18
+ * — each entry having to match the kit's `<project>-<env>-<capability>-<job>` rule and Cloudflare's segment
19
+ * rule, which `workflowKey` asserts at assembly. A mistake therefore failed at deploy, not at the point of
20
+ * writing.
21
+ *
22
+ * The names come from core's own `hostWorkflowsFor`, so an app-declared job is named by exactly the code
23
+ * that names a library one. Nothing here formats a name.
24
+ *
25
+ * **What still belongs to the adopter: the class.** Cloudflare resolves a `class_name` in the script named
26
+ * by the binding, so the `WorkflowEntrypoint` subclass has to be exported from the Worker's `main`. That is
27
+ * five lines written once. The per-environment binding table is not.
28
+ */
29
+
30
+ /**
31
+ * One `workflows` entry for a job the app capability declares. It carries **no `script_name`**: the class
32
+ * lives in this Worker's own `main`, so the binding is same-script. A library capability's entry does carry
33
+ * one, and that difference is how {@link reconcileAppWorkflows} tells the two apart in a stanza it did not
34
+ * write alone.
35
+ */
36
+ export interface AppOwnedWorkflow {
37
+ /** The binding name the Worker env exposes, e.g. `KEY_ROTATION`. */
38
+ binding: string;
39
+ /** The deployed Workflow name, `<project>-<env>-<capability>-<job>`. */
40
+ name: string;
41
+ /** The exported `WorkflowEntrypoint` subclass that runs the job. */
42
+ class_name: string;
43
+ }
44
+
45
+ /** What one environment's stanza should say: the app's own `workflows` entries, and the crons that fire them. */
46
+ export interface AppWorkflowPlan {
47
+ /** Complete entries, one per job the app declares, in declaration order. */
48
+ workflows: AppOwnedWorkflow[];
49
+ /** The declared cron schedules, deduplicated. Empty when no job is scheduled. */
50
+ crons: string[];
51
+ }
52
+
53
+ /** The identity an app-declared Workflow's name is composed from. */
54
+ export interface AppWorkflowNameParts {
55
+ /**
56
+ * The project name — the root `pithy.config.ts` `name`, from `requireProjectName` and never guessed.
57
+ * Workflow names are account-scoped, so a guessed project deploys under a name another project owns.
58
+ */
59
+ project: string;
60
+ /** The target environment. `dev` is a real environment here: it names the local Workflow too. */
61
+ env: string;
62
+ }
63
+
64
+ /**
65
+ * What one environment's stanza should say, derived from the app capability alone.
66
+ *
67
+ * Thin over core's {@link hostWorkflowsFor}, which already refuses a job with no `className` and already
68
+ * composes the name — the deliberate point being that an app-declared job and a library-declared one get
69
+ * their names from one function. The host's entries are the app's minus `script_name`, so the extra field
70
+ * is dropped rather than a second name-composer being written.
71
+ */
72
+ export function planAppWorkflows(app: Capability, parts: AppWorkflowNameParts): AppWorkflowPlan {
73
+ const registry = composeWorkflows([app]);
74
+ const { workflows, crons } = hostWorkflowsFor(registry, {
75
+ project: parts.project,
76
+ capability: app.name,
77
+ env: parts.env,
78
+ });
79
+ return {
80
+ workflows: workflows.map(({ binding, name, class_name }) => ({ binding, name, class_name })),
81
+ // Two jobs on one schedule are one cron: the Worker has a single `scheduled` handler and it fires
82
+ // every scheduled job on any tick, so a repeated expression is a duplicated run, not a second job.
83
+ crons: [...new Set(crons)],
84
+ };
85
+ }
86
+
87
+ /** The wrangler slice this module reads and writes. Unknown keys survive untouched — comment-json holds them. */
88
+ export interface WorkflowStanza {
89
+ workflows?: (AppOwnedWorkflow & { script_name?: string })[];
90
+ triggers?: { crons?: string[] };
91
+ }
92
+
93
+ /** The whole config: the top-level stanza (wrangler's default environment) plus each named one. */
94
+ export interface WorkflowConfig extends WorkflowStanza {
95
+ env?: Record<string, WorkflowStanza | undefined>;
96
+ }
97
+
98
+ /**
99
+ * Is this `workflows` entry the app capability's own?
100
+ *
101
+ * **One predicate, because the writer and the reader have to mean the same thing by it.** An entry
102
+ * carrying a `script_name` is a library capability's, written by that capability's provisioner and
103
+ * pointing at its host Worker. Everything else is same-script, which in this Worker means app-declared —
104
+ * that is what lets {@link reconcileAppWorkflows} replace the whole set rather than upsert it, and it is
105
+ * what `project/workflows.ts` reads back to ask whether the stanza binds what the app declares. Two
106
+ * copies of this rule would be two answers to "whose entry is this?", and drift the check could not see.
107
+ */
108
+ function isAppOwned(entry: { script_name?: string }): boolean {
109
+ return entry.script_name === undefined;
110
+ }
111
+
112
+ /**
113
+ * The app's own entries in one stanza's `workflows` table — the table {@link reconcileAppWorkflows}
114
+ * replaces, and the one the doctor and deploy readers compare against the declaration.
115
+ *
116
+ * The extra `script_name` field is not carried, because by definition these have none: the shape returned
117
+ * is exactly {@link planAppWorkflows}'s, so the comparison is between two values of one type.
118
+ */
119
+ export function appOwnedWorkflows(stanza: WorkflowStanza | undefined): AppOwnedWorkflow[] {
120
+ return (stanza?.workflows ?? [])
121
+ .filter(isAppOwned)
122
+ .map(({ binding, name, class_name }) => ({ binding, name, class_name }));
123
+ }
124
+
125
+ /** Options for {@link reconcileAppWorkflows}. */
126
+ export interface ReconcileAppWorkflowsOptions {
127
+ /** The Worker's directory — `apps/<name>`, where its `wrangler.jsonc` lives. */
128
+ workerDir: string;
129
+ /** The project name, from `requireProjectName`. */
130
+ project: string;
131
+ /** The Worker's own app capability — the one whose `workflows` map is the source of truth. */
132
+ app: Capability;
133
+ /** Narrow to one environment. Omitted reconciles the top-level stanza and every `env.<name>` already declared. */
134
+ env?: string;
135
+ }
136
+
137
+ /** What one environment's reconciliation did. */
138
+ export interface AppWorkflowRun {
139
+ /** The environment reconciled — `dev` for the top-level stanza. */
140
+ env: string;
141
+ /** The entries its `workflows` table now declares for the app, verbatim as written. */
142
+ workflows: AppOwnedWorkflow[];
143
+ /** The cron schedules its `triggers` now carries. */
144
+ crons: string[];
145
+ /** Whether anything moved. False on a re-run with nothing to change. */
146
+ changed: boolean;
147
+ }
148
+
149
+ /**
150
+ * The environments a run visits: the one named, else the top-level stanza plus every `env.<name>` the
151
+ * Worker already declares.
152
+ *
153
+ * Deriving the set from the file is the point — the defect was writing the same table once per environment
154
+ * by hand, so an adopter who adds `prod` later must not have to remember this command's argument list. An
155
+ * explicit `--env` still creates a stanza that is not there yet, because naming one is asking for it.
156
+ */
157
+ function environmentsOf(config: WorkflowConfig, env: string | undefined): string[] {
158
+ if (env !== undefined) return [env];
159
+ return ["dev", ...Object.keys(config.env ?? {})];
160
+ }
161
+
162
+ /** The stanza for an environment, created when absent. `dev` is the top-level one — wrangler has no `env.dev`. */
163
+ function stanzaFor(config: WorkflowConfig, env: string): WorkflowStanza {
164
+ if (env === "dev") return config;
165
+ config.env ??= {};
166
+ const existing = config.env[env] ?? {};
167
+ config.env[env] = existing;
168
+ return existing;
169
+ }
170
+
171
+ /**
172
+ * Replace the app's own entries in one stanza, leaving every provisioned one in place.
173
+ *
174
+ * An entry carrying a `script_name` is a library capability's, written by that capability's provisioner and
175
+ * pointing at its host Worker — untouchable here. Everything else is same-script, which in this Worker means
176
+ * app-declared, so the whole set is replaced rather than upserted: a job the app renamed or dropped must
177
+ * leave, and an upsert by binding name would strand it. The provisioned entries keep their positions ahead
178
+ * of the app's, so a re-run produces a byte-identical file.
179
+ */
180
+ function replaceOwnWorkflows(stanza: WorkflowStanza, plan: AppWorkflowPlan): void {
181
+ const provisioned = (stanza.workflows ?? []).filter((entry) => !isAppOwned(entry));
182
+ const next = [...provisioned, ...plan.workflows];
183
+ if (stanza.workflows) {
184
+ // In place: comment-json keeps an array's comments as symbol-keyed properties on the array object,
185
+ // so a fresh array would silently drop the adopter's notes.
186
+ stanza.workflows.length = 0;
187
+ stanza.workflows.push(...next);
188
+ } else if (next.length > 0) {
189
+ stanza.workflows = next;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Set the stanza's cron schedule to what the app declares.
195
+ *
196
+ * Set, not merge. `createEntrypoint` gives a Worker one `scheduled` handler that starts **every** job
197
+ * carrying a schedule, whatever cron fired — so an expression nothing declares is not an extra job, it is
198
+ * every job running again at a time nobody asked for. The declaration is therefore the whole truth, and a
199
+ * schedule the adopter changes takes its old value with it.
200
+ *
201
+ * **An emptied schedule is written as `[]`, never as a deleted key.** Wrangler reads an absent `crons` as
202
+ * "not declared" and leaves the deployed Worker's schedule exactly as it was, so deleting the key when the
203
+ * app drops its last schedule would leave the old cron firing every job that remains — a reconcile that
204
+ * reports `Done.` and changes nothing where it matters. A stanza that never carried crons is still left
205
+ * alone, because a `triggers` block a project never had is noise in every config whose jobs all dispatch.
206
+ */
207
+ function setCrons(stanza: WorkflowStanza, crons: string[]): void {
208
+ if (crons.length === 0 && stanza.triggers?.crons === undefined) return;
209
+ stanza.triggers ??= {};
210
+ stanza.triggers.crons = crons;
211
+ }
212
+
213
+ /**
214
+ * Reconcile the app capability's declared Workflows and cron schedule into the Worker's `wrangler.jsonc` —
215
+ * the seam behind `pithy worker sync`.
216
+ *
217
+ * Idempotent, comment-preserving, and all-or-nothing: every environment is computed and checked before the
218
+ * file is written once, so a stanza wrangler would reject aborts the run rather than leaving half a config
219
+ * behind. An app that declares no Workflows writes nothing at all — including no empty `workflows` key,
220
+ * which wrangler reads as a declaration.
221
+ *
222
+ * **An app that declares none is still reconciled**, and that is not the same statement. It used to
223
+ * return before the file was opened, which made "the declaration is the truth" false in the one case
224
+ * where it matters most: drop the last job from `pithy.config.ts` and the binding and the cron stayed in
225
+ * `wrangler.jsonc` forever, with no command that would take them out and — since #267 — a doctor fault
226
+ * naming a command that could not answer it. The empty declaration is a declaration. What it writes is
227
+ * still nothing at all where there was nothing: `replaceOwnWorkflows` creates no `workflows` key and
228
+ * `setCrons` creates no `triggers` block, so a project that never had either is byte-identical after.
229
+ */
230
+ export async function reconcileAppWorkflows(options: ReconcileAppWorkflowsOptions): Promise<AppWorkflowRun[]> {
231
+ const { workerDir, project, app, env } = options;
232
+
233
+ const config = (await readWranglerConfig(workerDir)) as WorkflowConfig;
234
+ const before = stringify(config);
235
+
236
+ const runs: AppWorkflowRun[] = [];
237
+ for (const target of environmentsOf(config, env)) {
238
+ const plan = planAppWorkflows(app, { project, env: target });
239
+ const stanza = stanzaFor(config, target);
240
+ const stanzaBefore = stringify(stanza);
241
+
242
+ replaceOwnWorkflows(stanza, plan);
243
+ setCrons(stanza, plan.crons);
244
+
245
+ // Never write a config wrangler will not load. A hand-edited entry that lost a field lands here too,
246
+ // which is the right place to hear about it — before the next deploy.
247
+ const problems = incompleteBindings(stanza);
248
+ if (problems.length > 0) {
249
+ throw new InternalError({
250
+ message: `wrangler.jsonc would not load with the ${target} bindings.`,
251
+ action: `Fix the ${target} bindings in ${workerDir}/wrangler.jsonc by hand, then run pithy worker sync again.`,
252
+ detail: problems.join("; "),
253
+ });
254
+ }
255
+
256
+ runs.push({
257
+ env: target,
258
+ workflows: plan.workflows,
259
+ crons: plan.crons,
260
+ changed: stringify(stanza) !== stanzaBefore,
261
+ });
262
+ }
263
+
264
+ if (stringify(config) !== before) await writeWranglerConfig(workerDir, config);
265
+ return runs;
266
+ }
@@ -0,0 +1,166 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DOMAIN_ENVIRONMENTS, domainFor, originFor, type WorkerDomains } from "@pithy-sh/core/src/naming/domains";
5
+ import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
6
+
7
+ /**
8
+ * Generate a Worker's `routes` and `vars.BASE_URL` from its `domains` declaration.
9
+ *
10
+ * One declaration in, two derived wrangler values out, per environment. Nothing is hand-maintained: the
11
+ * whole point of #89 Part 1 is that an adopter states the address once and everything downstream of it is
12
+ * computed, so a route and a `BASE_URL` can no longer disagree with each other or with the declaration.
13
+ *
14
+ * `BASE_URL` stays a wrangler var rather than moving into the declaration alone, because the **Worker
15
+ * reads it at runtime** — `@pithy-sh/email` builds tracking and unsubscribe links against it, and the
16
+ * email Workflow host has it stamped into its own config at provision time. What changed is that it is
17
+ * derived rather than hand-set.
18
+ *
19
+ * ## Per environment, always
20
+ *
21
+ * `env.<name>` stanzas **replace** the top level rather than merging with it, so a value written once at
22
+ * the top would be invisible to staging and prod. That is the same trap `scaffoldProject` handles with
23
+ * `replaceAll` for `PROJECT`/`WORKER`, and the reason nothing here writes to the top-level stanza: `dev`
24
+ * has no domain by design.
25
+ *
26
+ * ## In place, never replaced
27
+ *
28
+ * `comment-json` stores an adopter's comments as symbol-keyed properties on the very array or object they
29
+ * hang off, so replacing an array deletes their notes. Every mutation here reuses the existing container.
30
+ */
31
+
32
+ /** One environment's derived values, for the caller to report. */
33
+ export interface AppliedDomain {
34
+ /** The environment written. */
35
+ env: string;
36
+ /** The hostname the route now points at. */
37
+ pattern: string;
38
+ /** The base URL written to `vars.BASE_URL`. */
39
+ baseUrl: string;
40
+ /**
41
+ * Whether this environment's stanza actually changed.
42
+ *
43
+ * Carried so a command can say "already in sync" and mean it. The write was always idempotent in bytes;
44
+ * this makes the *report* idempotent too, which is the difference between a reconcile command an
45
+ * adopter runs freely and one that appears to do something every time they run it.
46
+ */
47
+ changed: boolean;
48
+ }
49
+
50
+ /** The wrangler shape this writes. Only the keys it owns. */
51
+ interface DomainStanza {
52
+ routes?: unknown[];
53
+ route?: unknown;
54
+ vars?: Record<string, unknown>;
55
+ workers_dev?: unknown;
56
+ }
57
+ interface DomainWrangler extends DomainStanza {
58
+ env?: Record<string, DomainStanza | undefined>;
59
+ }
60
+
61
+ /** A route entry as this generator writes one — a custom domain attached to its zone. */
62
+ interface CustomDomainRoute {
63
+ pattern?: string;
64
+ custom_domain?: boolean;
65
+ zone_name?: string;
66
+ }
67
+
68
+ /**
69
+ * Upsert the custom-domain route for one environment, in place.
70
+ *
71
+ * Matches on `custom_domain: true` rather than on the pattern, because the pattern is precisely what may
72
+ * have changed — matching on it would append a second entry for the same Worker every time an adopter
73
+ * moved their domain, and wrangler would then serve whichever it liked. Any other route entry the adopter
74
+ * wrote is left exactly where it is: this owns the custom domain, not the route list.
75
+ */
76
+ function upsertRoute(stanza: DomainStanza, pattern: string, zone: string): void {
77
+ if (!Array.isArray(stanza.routes)) stanza.routes = [];
78
+ const routes = stanza.routes;
79
+ const existing = routes.find(
80
+ (entry): entry is CustomDomainRoute =>
81
+ typeof entry === "object" && entry !== null && (entry as CustomDomainRoute).custom_domain === true,
82
+ );
83
+ if (existing) {
84
+ existing.pattern = pattern;
85
+ existing.zone_name = zone;
86
+ return;
87
+ }
88
+ routes.push({ pattern, custom_domain: true, zone_name: zone });
89
+ }
90
+
91
+ /**
92
+ * Turn off `workers.dev` for an environment that now has a custom domain — unless the adopter has
93
+ * already said what they want.
94
+ *
95
+ * **A declared domain is the origin, and `workers.dev` is a second one nothing declared.** Wrangler's
96
+ * `workers_dev` defaults to `true` and declaring `routes` does not change it, so a Worker with a custom
97
+ * domain also answers on `<name>.<subdomain>.workers.dev` — and `preview_urls` defaults to whatever
98
+ * `workers_dev` is, so every deployed version is reachable there too. `vars.BASE_URL` beside it names
99
+ * only the custom domain, so on that second origin the OAuth callbacks and magic links point elsewhere
100
+ * and the CSRF same-origin gate refuses the very requests that establish who you are. Reachable, and
101
+ * broken in exactly that half. Anything bound to the hostname rather than the script — a WAF rule, an
102
+ * Access policy, a per-hostname rate limit — does not apply there at all.
103
+ *
104
+ * **Written only when the key is absent, unlike the route and `BASE_URL` beside it.** Those two are
105
+ * *derived* from the declaration and are overwritten every run, because a stale one contradicts it.
106
+ * This is not derived: the declaration makes `false` the right default and does not make it the only
107
+ * answer. A team that wants the `workers.dev` URL for staging until DNS is cut over writes
108
+ * `"workers_dev": true`, and that is a named origin rather than an unnamed one — which is the whole
109
+ * distinction `originDrift` is built on. Overwriting it would delete the sentence they wrote.
110
+ */
111
+ function closeWorkersDev(stanza: DomainStanza): void {
112
+ if (typeof stanza.workers_dev === "boolean") return;
113
+ stanza.workers_dev = false;
114
+ }
115
+
116
+ /**
117
+ * Write the declaration into a Worker's `wrangler.jsonc`. Returns what it wrote, per environment.
118
+ *
119
+ * Idempotent: a second run changes nothing and writes nothing, and running it after an adopter moved their
120
+ * domain updates the one route entry rather than appending another. An environment with no declared domain
121
+ * is left completely alone — never cleared, because an adopter may have written a route by hand and this
122
+ * must not delete it just because they have not adopted the declaration for that environment.
123
+ *
124
+ * **This is the only thing that writes a route, and until #264 the only way to reach it was a prompt.**
125
+ * Its one caller was `writeDomains`, called only from an interactive `pithy init` or `pithy worker add` —
126
+ * and `askDomains` returns nothing at all when a session is not interactive. So a `--json` scaffold, a CI
127
+ * run, and every adopter who added `domains` to a `pithy.config.ts` by hand got a declaration with no route
128
+ * behind it, and nothing downstream ever noticed. `pithy worker sync` is the second caller, and the
129
+ * non-interactive one: the declaration goes in the config, the command writes what it implies.
130
+ */
131
+ export async function applyDomains(workerDir: string, domains: WorkerDomains): Promise<AppliedDomain[]> {
132
+ const config = (await readWranglerConfig(workerDir)) as DomainWrangler;
133
+ const applied: AppliedDomain[] = [];
134
+
135
+ for (const env of DOMAIN_ENVIRONMENTS) {
136
+ const domain = domainFor(domains, env);
137
+ if (!domain) continue;
138
+
139
+ // `dev` is never in `DOMAIN_ENVIRONMENTS`, so this only ever reaches an `env.<name>` stanza — which is
140
+ // also the only place the values would be read from, since env stanzas replace the top level.
141
+ config.env ??= {};
142
+ // Structural, before anything is touched. `JSON.stringify` sees none of comment-json's symbol-keyed
143
+ // comment properties, which is exactly right here: a run that changes no value changed nothing, and
144
+ // an adopter's comments are not a diff.
145
+ const before = JSON.stringify(config.env[env] ?? null);
146
+ config.env[env] ??= {};
147
+ const stanza = config.env[env];
148
+
149
+ upsertRoute(stanza, domain.pattern, domain.zone);
150
+ stanza.vars ??= {};
151
+ // Through `originFor`, never `baseUrlFor` directly — the same call an adopter's `pithy.config.ts`
152
+ // makes to hand a capability its origin (#256). That is what makes "`vars.BASE_URL` and the
153
+ // capability configs cannot disagree" a property of the code rather than a thing to remember.
154
+ const baseUrl = originFor(env, domains);
155
+ stanza.vars.BASE_URL = baseUrl;
156
+ closeWorkersDev(stanza);
157
+
158
+ applied.push({ env, pattern: domain.pattern, baseUrl, changed: before !== JSON.stringify(stanza) });
159
+ }
160
+
161
+ // Nothing changed, nothing written. The bytes were already identical, so the only thing skipping the
162
+ // write removes is an mtime bump on a file the adopter has open — and the one thing a reconcile command
163
+ // must never do is look like it edited something it did not.
164
+ if (applied.some((entry) => entry.changed)) await writeWranglerConfig(workerDir, config);
165
+ return applied;
166
+ }