@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,701 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import { access, copyFile, rm } from "node:fs/promises";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import type { ProfileOverride } from "@pithy-sh/cloudflare/src/tokens/profiles";
9
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
10
+ import {
11
+ causeMessage,
12
+ failurePosition,
13
+ isBuildFailureWrapper,
14
+ prop,
15
+ rootCause,
16
+ safeReason,
17
+ unresolvedSpecifier,
18
+ } from "@pithy-sh/core/src/error/cause";
19
+ import { fromZodError, InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
20
+ import { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
21
+ import { DEFAULT_ENVIRONMENTS, DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
22
+ import { assertValidProjectName, kebab } from "@pithy-sh/core/src/naming/resource";
23
+ import { z } from "zod";
24
+ import { CloudflareAccountName } from "../cloudflare/config";
25
+ import { discoverWorkers } from "./workers";
26
+
27
+ /** Adopter token configuration: per-profile overrides of the predefined defaults (permissions/resources/store). */
28
+ export interface TokenConfig {
29
+ /** Profile name → the fields to override on that profile's predefined default. */
30
+ overrides?: Record<string, ProfileOverride>;
31
+ }
32
+
33
+ /** Adopter `pithy seed` configuration. */
34
+ export interface SeedProjectConfig {
35
+ /**
36
+ * Compose in `example`-flagged seed sets (tiny demo fixtures a capability ships for a quick look).
37
+ * Default off — an adopter opts in per project, and an example set never targets production
38
+ * regardless of this setting (its own `environments` allowlist excludes it).
39
+ */
40
+ includeExamples?: boolean;
41
+ /**
42
+ * Environment names this project treats as production, beyond the built-in `production`/`prod`.
43
+ * Any env named here (case-insensitive) requires the hard type-to-confirm phrase, not just `--yes` —
44
+ * so a project whose production environment is named `live`, `prod-eu`, `main`, etc. gets the same
45
+ * strongest gate as the canonical names. List every production-class environment you run.
46
+ */
47
+ productionEnvironments?: readonly string[];
48
+ }
49
+
50
+ /**
51
+ * The **root** `pithy.config.ts`: project identity and project-wide policy. It deliberately carries no
52
+ * capabilities — what a Worker is *made of* is per-Worker and lives in `apps/<name>/pithy.config.ts`
53
+ * ({@link WorkerConfig}). These three settings are the ones that cannot be per-Worker:
54
+ *
55
+ * - `name` is the first segment of every feature resource name and the only key teardown has to find them by,
56
+ * so it must be one stable value for the whole project.
57
+ * - `environments` is the set of environments the project has — the second segment of the same names, and
58
+ * the set every command that iterates environments has to agree on.
59
+ * - `tokens` configures account-level Cloudflare API token profiles.
60
+ * - `seed.productionEnvironments` is a safety policy; a Worker must not be able to quietly omit it.
61
+ */
62
+ export interface ProjectConfig {
63
+ /**
64
+ * The project name — a short, hyphenated-lowercase identifier (e.g. `acme`). It is the branch-first
65
+ * prefix `pithy feature` names every Cloudflare resource under (`<project>-f<issue>-<slug>-<resource>`),
66
+ * so the CF dashboard groups a feature's resources and teardown finds them by prefix. Optional; when
67
+ * absent it falls back to the app Worker's `wrangler.jsonc` name, then the project directory name.
68
+ */
69
+ name?: string;
70
+ /**
71
+ * Every deployed environment this project has, e.g. `["staging", "prod"]` — the **second** segment of
72
+ * every Cloudflare name it composes, and the other half of the budget `name` is already governed by.
73
+ * Optional; absent means {@link DEFAULT_ENVIRONMENTS}, which is what the scaffold hardcoded before this
74
+ * setting existed.
75
+ *
76
+ * Typed `unknown` for the same reason {@link ProjectConfig.cloudflare} is: this file is the adopter's own
77
+ * TypeScript, `loadProject` imports it live, and duck-typing is the only other gate on it — so an
78
+ * unvalidated entry would reach a resource name as whatever they typed. {@link loadProjectEnvironments}
79
+ * is the gate.
80
+ */
81
+ environments?: unknown;
82
+ /** Overrides for the predefined CF token profiles (`pithy token`). Optional. */
83
+ tokens?: TokenConfig;
84
+ /** `pithy seed` settings. Optional; defaults to no example seeds. */
85
+ seed?: SeedProjectConfig;
86
+ /**
87
+ * Which Cloudflare account this project belongs to — `{ accountName?, accountId? }`, validated by
88
+ * {@link loadProjectCloudflare}. Optional, and absent means exactly what it always meant: the
89
+ * credentials come from `<config>/cloudflare.json`.
90
+ *
91
+ * Typed `unknown` for the same reason {@link WorkerConfig.domains} is: this file is the adopter's own
92
+ * TypeScript, `loadProject` imports it live, and duck-typing is the only other gate on it — so an
93
+ * unvalidated `accountName` would reach a `join` as whatever they typed.
94
+ */
95
+ cloudflare?: unknown;
96
+ }
97
+
98
+ /**
99
+ * The root config's `cloudflare` block: **which Cloudflare account this project belongs to** (#206).
100
+ *
101
+ * `<config>/cloudflare.json` is account-scoped, on the reasoning that one account holds many projects
102
+ * (#182). That reasoning is right and incomplete: it assumes one account per *machine*. A developer
103
+ * working across two companies has two, and without this block every project on the machine reads the
104
+ * same file — so switching accounts means editing that file in place and every project silently follows.
105
+ *
106
+ * **Strict, because a dropped key here is the whole failure mode.** A misspelled `accountid` that parsed
107
+ * to "no pin" would leave the project with exactly the silent, unverified resolution the pin exists to
108
+ * end, and nothing would say so.
109
+ */
110
+ export const ProjectCloudflare = z
111
+ .strictObject({
112
+ accountName: CloudflareAccountName.optional().describe(
113
+ "Selects <config>/cloudflare.<name>.json. Absent selects cloudflare.json, exactly as before.",
114
+ ),
115
+ accountId: z
116
+ .string()
117
+ .min(1)
118
+ .optional()
119
+ .describe(
120
+ "The account this project belongs to. An identifier, not a secret — safe in a repository, including a public one — and verified against whatever the credentials resolve to.",
121
+ ),
122
+ })
123
+ .describe("Which Cloudflare account this project's credentials must belong to, from the root pithy.config.ts.");
124
+
125
+ /** Which Cloudflare account this project belongs to. Same name as its schema, as every Zod object here is. */
126
+ export type ProjectCloudflare = z.output<typeof ProjectCloudflare>;
127
+
128
+ /**
129
+ * Read and validate the root config's `cloudflare` declaration.
130
+ *
131
+ * Parsed rather than cast, on {@link loadWorkerDomains}'s argument and one more of its own: `accountName`
132
+ * becomes a **file name** in the config directory, which sits outside every checkout — so
133
+ * `ensureScaffoldPath` and the atomic writer, the two things that guard a path inside a project, never
134
+ * see it. {@link CloudflareAccountName} is the gate, and this is where a project's config meets it.
135
+ *
136
+ * Absent is not an error; it is the ordinary state of a machine with one Cloudflare account.
137
+ */
138
+ export function loadProjectCloudflare(config: ProjectConfig): ProjectCloudflare | undefined {
139
+ if (config.cloudflare === undefined || config.cloudflare === null) return undefined;
140
+ const parsed = ProjectCloudflare.safeParse(config.cloudflare);
141
+ if (!parsed.success) {
142
+ // The schema's own sentences are promoted into `message` rather than left in `issues`, because the
143
+ // CLI's error renderer prints `message` and `action` and nothing else — and the whole point of the
144
+ // rule living at the schema is that its refusal names the config and the value wherever it surfaces.
145
+ throw fromZodError(parsed.error, {
146
+ message: `The \`cloudflare\` block in pithy.config.ts is not valid. ${parsed.error.issues.map((issue) => issue.message).join(" ")}`,
147
+ action:
148
+ "Fix `cloudflare` in the root pithy.config.ts. It takes `accountName` (a bare token selecting <config>/cloudflare.<name>.json) and `accountId` (the account the project belongs to).",
149
+ });
150
+ }
151
+ return parsed.data;
152
+ }
153
+
154
+ /**
155
+ * Read and validate the root config's `environments` declaration — **the project's environment set, in
156
+ * one place, for every command that iterates environments** (#241).
157
+ *
158
+ * Before this, nothing said. The set existed only as `env.<name>` stanzas in each Worker's
159
+ * `wrangler.jsonc` — per Worker, so two Workers could disagree with nobody reconciling them — while
160
+ * `ManagedEnvironment` held a closed enum of two and `seed.productionEnvironments` invited a project to
161
+ * name a third. An adopter adding `env.live` got a working `pithy migrate --env live` and a
162
+ * `pithy secrets provision` that skipped it in silence.
163
+ *
164
+ * Absent is not an error: it is the ordinary state of a project that runs staging and prod, and it answers
165
+ * {@link DEFAULT_ENVIRONMENTS} — the pair the scaffold hardcoded — so nothing about an existing project
166
+ * changes until it says otherwise.
167
+ *
168
+ * **Validated on every load, not at the first provision.** An environment name reaches Cloudflare resource
169
+ * names verbatim and a provisioned project cannot be renamed, so the one place that can still say no is
170
+ * before any command has acted on it.
171
+ */
172
+ export function loadProjectEnvironments(config: ProjectConfig): DeclaredEnvironments {
173
+ if (config.environments === undefined || config.environments === null) return [...DEFAULT_ENVIRONMENTS];
174
+ const parsed = DeclaredEnvironments.safeParse(config.environments);
175
+ if (!parsed.success) {
176
+ // The schema's own sentences are promoted into `message`, as the `cloudflare` block's are, because the
177
+ // CLI's error renderer prints `message` and `action` and nothing else.
178
+ throw fromZodError(parsed.error, {
179
+ message: `The \`environments\` declaration in pithy.config.ts is not valid. ${parsed.error.issues.map((issue) => issue.message).join(" ")}`,
180
+ action: `Fix \`environments\` in the root pithy.config.ts. It is a list of deployed environment names, least-production first — e.g. ${JSON.stringify([...DEFAULT_ENVIRONMENTS])}. Changing it does not rename anything already provisioned.`,
181
+ });
182
+ }
183
+ return parsed.data;
184
+ }
185
+
186
+ /**
187
+ * The environments a project has, loaded from its root config — the counterpart to
188
+ * {@link projectCloudflareAccount}, for a command that has a directory rather than a loaded config.
189
+ */
190
+ export async function projectEnvironments(projectDir: string): Promise<DeclaredEnvironments> {
191
+ return loadProjectEnvironments(await loadProject(projectDir));
192
+ }
193
+
194
+ /**
195
+ * One Worker's `apps/<name>/pithy.config.ts`: what *that* Worker is made of. Capabilities are per-Worker
196
+ * because everything they drive is per-Worker — the composed route tree (`createEntrypoint`), the
197
+ * `requiredBindings` written into that Worker's `wrangler.jsonc`, and Durable Object class migrations, which
198
+ * register a class against a specific script. A Worker that only needs KV composes only what it declares.
199
+ *
200
+ * Workers share a resource by declaring the **same binding name**: feature resource names are derived from
201
+ * `(project, issue, slug, binding, kind)` with no Worker segment, so two Workers that both declare `DB` are
202
+ * backed by one D1, and a Worker wanting its own declares a different binding (e.g. `COLLAB_DB`).
203
+ */
204
+ export interface WorkerConfig {
205
+ /** Library capabilities this Worker composes, in order. `pithy add --worker <name>` registers them here. */
206
+ capabilities: Capability[];
207
+ /** This Worker's own app capability, composed last. */
208
+ app?: Capability;
209
+ /**
210
+ * Where this Worker answers, per environment — the one declaration `routes`, `vars.BASE_URL`, and every
211
+ * command that needs an address are derived from. Optional: a project without a domain yet is
212
+ * legitimate, and adding one later is a config edit plus a deploy.
213
+ *
214
+ * Validated through {@link WorkerDomains} by {@link loadWorkerDomains} rather than trusted off the
215
+ * import, because this file is the adopter's own TypeScript and nothing else checks it.
216
+ */
217
+ domains?: unknown;
218
+ /**
219
+ * Optional bindings this Worker will not have, each mapped to the reason it will not.
220
+ *
221
+ * A capability marks a binding `optional` when its own code has a path for the binding's absence.
222
+ * Naming one here says this Worker takes that path deliberately: `pithy upgrade` leaves it out of
223
+ * `wrangler.jsonc`, and `pithy doctor` reports it as declined rather than missing — so a stanza
224
+ * deleted by hand stays deleted instead of returning on the next upgrade (#440).
225
+ *
226
+ * **The capability's own config is the first place to look.** Turning a feature off is a better
227
+ * answer than declining its binding, because the capability then declares nothing and there is
228
+ * nothing to decline. This is for the case where the feature is on and the resource is deliberately
229
+ * not provisioned.
230
+ *
231
+ * Typed `unknown` for the same reason {@link WorkerConfig.domains} is, and read through
232
+ * {@link readDeclinedBindings} — which reports rather than throws, because `pithy doctor` must
233
+ * still produce a report for a Worker whose declaration is malformed.
234
+ */
235
+ declinedBindings?: unknown;
236
+ }
237
+
238
+ /**
239
+ * The reason a Worker gives for declining an optional binding.
240
+ *
241
+ * **Required, and that is the feature.** A silent omission is what existed before #440 and is
242
+ * indistinguishable from having forgotten; `pithy doctor` prints this back on every run, so the next
243
+ * person reads why rather than guessing whether the absence was a choice. Single-line and bounded
244
+ * because it lands in a fixed-width terminal report beside the binding it explains.
245
+ */
246
+ export const DeclineReason = z
247
+ .string()
248
+ .trim()
249
+ .min(1)
250
+ .max(160)
251
+ .refine((reason) => !/[\r\n]/.test(reason), { message: "A reason is one line." })
252
+ .describe(
253
+ "Why this Worker will not have the binding. Printed back by `pithy doctor` on every run, so write it for the next person: one line, up to 160 characters.",
254
+ );
255
+ export type DeclineReason = z.infer<typeof DeclineReason>;
256
+
257
+ /**
258
+ * A Worker's declined optional bindings — binding name to reason.
259
+ *
260
+ * A record rather than an array of objects because the binding name is the key everything downstream
261
+ * joins on, and a record makes a name declared twice unrepresentable rather than a case to resolve.
262
+ */
263
+ /**
264
+ * A Worker env binding name, as a capability declares it and wrangler writes it.
265
+ *
266
+ * Its own schema rather than the reason's: keying a record on `DeclineReason` typechecked and held, but
267
+ * it reported "A reason is one line." about a malformed *key*, silently trimmed `" DB "` into `DB`, and
268
+ * let a 160-character sentence stand where a binding name belongs. A key and a value are different
269
+ * things and the failure an adopter reads should say which one they got wrong.
270
+ *
271
+ * Shaped the way every binding in the kit is: `SCREAMING_SNAKE_CASE`. That is a real constraint rather
272
+ * than a style note — an entry that cannot name a binding can only ever resolve `unrecognized`, so
273
+ * refusing it here turns a line that would be silently inert into one that says so.
274
+ */
275
+ export const BindingName = z
276
+ .string()
277
+ .regex(/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/, {
278
+ message: "A binding name is SCREAMING_SNAKE_CASE, like `DB` or `SUPPORT_BUCKET`.",
279
+ })
280
+ .max(64)
281
+ .describe("A Worker env binding name, as the capability declares it — SCREAMING_SNAKE_CASE.");
282
+ export type BindingName = z.infer<typeof BindingName>;
283
+
284
+ export const DeclinedBindings = z
285
+ .record(BindingName, DeclineReason)
286
+ .describe(
287
+ "Optional bindings this Worker will not have, keyed by binding name. `pithy upgrade` leaves each out of wrangler.jsonc; `pithy doctor` reports it as declined rather than missing.",
288
+ );
289
+ export type DeclinedBindings = z.infer<typeof DeclinedBindings>;
290
+
291
+ /**
292
+ * What a Worker's `declinedBindings` declaration turned out to be.
293
+ *
294
+ * `invalid` rather than a throw: `pithy doctor` reads this, and a doctor that refuses to report
295
+ * because one declaration is malformed is a doctor that goes silent exactly when something is wrong.
296
+ * `pithy upgrade` turns the same state into a refusal at its own gate, before it writes anything.
297
+ */
298
+ export type DeclinedBindingsRead =
299
+ | { state: "read"; declared: DeclinedBindings }
300
+ | { state: "invalid"; problem: string };
301
+
302
+ /**
303
+ * The spellings of `declinedBindings` that are near enough to be a typo rather than an unrelated key.
304
+ *
305
+ * Compared on lowercase alphanumerics, so `declinedBinding`, `declineBindings` and `declined_bindings`
306
+ * all land here. An adopter's own unrelated key is untouched — this only fires on something that was
307
+ * plainly meant to be this one and would otherwise be ignored in silence, which is the failure mode the
308
+ * whole feature exists to remove.
309
+ */
310
+ const DECLINE_NEAR_MISSES = [
311
+ // The canonical key's own normalization, which catches every case difference and separator an
312
+ // adopter might reach for: `declined_bindings`, `DeclinedBindings`, `declined-bindings`.
313
+ "declinedbindings",
314
+ "declinedbinding",
315
+ "declinebindings",
316
+ "declinebinding",
317
+ "declinedbindingnames",
318
+ ];
319
+
320
+ /** Normalize a config key for near-miss comparison: lowercase, letters and digits only. */
321
+ function normalizeKey(key: string): string {
322
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
323
+ }
324
+
325
+ /**
326
+ * Read a Worker's `declinedBindings` declaration.
327
+ *
328
+ * Absent is not an error; it is the ordinary state of every Worker that declines nothing. A key that is
329
+ * *nearly* `declinedBindings` is an error, because the scaffolded `const config = { … }` is an
330
+ * unannotated literal — TypeScript accepts the misspelling, nothing reads it, and the binding an
331
+ * adopter thought they had declined comes back on the next upgrade with no line anywhere saying why.
332
+ */
333
+ export function readDeclinedBindings(config: WorkerConfig): DeclinedBindingsRead {
334
+ const record = config as unknown as Record<string, unknown>;
335
+ const nearMiss = Object.keys(record).find(
336
+ (key) => key !== "declinedBindings" && DECLINE_NEAR_MISSES.includes(normalizeKey(key)),
337
+ );
338
+ if (nearMiss !== undefined) {
339
+ return {
340
+ state: "invalid",
341
+ problem: `\`${nearMiss}\` is not a key this Worker's config declares. Did you mean \`declinedBindings\`?`,
342
+ };
343
+ }
344
+ if (record.declinedBindings === undefined || record.declinedBindings === null) {
345
+ return { state: "read", declared: {} };
346
+ }
347
+ const parsed = DeclinedBindings.safeParse(record.declinedBindings);
348
+ if (!parsed.success) {
349
+ return {
350
+ state: "invalid",
351
+ problem: parsed.error.issues
352
+ .map((issue) => `${issue.path.join(".") || "declinedBindings"}: ${issue.message}`)
353
+ .join("; "),
354
+ };
355
+ }
356
+ return { state: "read", declared: parsed.data };
357
+ }
358
+
359
+ /**
360
+ * Read and validate a Worker's `domains` declaration.
361
+ *
362
+ * Parsed rather than cast: `pithy.config.ts` is the adopter's own module, `loadWorkerConfig` imports it
363
+ * live, and duck-typing is the only other gate on it — so an unvalidated `domains` would reach the
364
+ * wrangler generator as whatever they typed. A malformed declaration must fail with the field named,
365
+ * not produce a `routes` entry Cloudflare rejects at deploy.
366
+ *
367
+ * Absent is not an error; it is the ordinary state of a project that has not wired a domain yet.
368
+ */
369
+ export function loadWorkerDomains(config: WorkerConfig): WorkerDomains | undefined {
370
+ if (config.domains === undefined || config.domains === null) return undefined;
371
+ const parsed = WorkerDomains.safeParse(config.domains);
372
+ if (!parsed.success) {
373
+ throw fromZodError(parsed.error, {
374
+ message: "This Worker's `domains` declaration is not valid.",
375
+ action: "Fix `domains` in the Worker's pithy.config.ts. Each entry is `{ pattern, zone }` — bare hostnames.",
376
+ });
377
+ }
378
+ return parsed.data;
379
+ }
380
+
381
+ function isWorkerConfig(value: unknown): value is WorkerConfig {
382
+ return typeof value === "object" && value !== null && Array.isArray((value as WorkerConfig).capabilities);
383
+ }
384
+
385
+ function isProjectConfig(value: unknown): value is ProjectConfig {
386
+ return typeof value === "object" && value !== null;
387
+ }
388
+
389
+ /**
390
+ * Why a `pithy.config.ts` would not import. Three causes an adopter fixes three different ways, plus the
391
+ * honest fourth: the classifier does not recognize this one and will not invent a remedy for it.
392
+ */
393
+ export type ConfigLoadFailureKind = "unresolved-import" | "parse-error" | "threw-on-load" | "unknown";
394
+
395
+ /** A classified config-load failure: what went wrong, and the one sentence that fits it. */
396
+ export interface ConfigLoadFailure {
397
+ /** Which of the four causes this is. */
398
+ kind: ConfigLoadFailureKind;
399
+ /** The `action` line for the refusal — chosen from the failure, never asserted over it. */
400
+ action: string;
401
+ }
402
+
403
+ /**
404
+ * The message, de-colored, for this classifier's own pattern tests — never for output.
405
+ *
406
+ * What may be *said* is core's {@link safeReason}, and only core's: the filter that decides whether a
407
+ * runtime's string is fit to show lived here, in the capability loaders, and in the vite plugin, in three
408
+ * near-verbatim copies, and the hole #223 found in it had to be closed in all three (#228). What stays
409
+ * here is the policy — which causes this classifier recognizes, and what it says about each.
410
+ */
411
+ function rawMessage(cause: unknown): string {
412
+ return causeMessage(cause) ?? "";
413
+ }
414
+
415
+ function isUnresolvedImport(cause: unknown): boolean {
416
+ const code = prop(cause, "code");
417
+ if (
418
+ code === "ERR_MODULE_NOT_FOUND" ||
419
+ code === "MODULE_NOT_FOUND" ||
420
+ code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
421
+ code === "ERR_UNSUPPORTED_DIR_IMPORT"
422
+ ) {
423
+ return true;
424
+ }
425
+ if (prop(cause, "name") === "ResolveMessage") return true;
426
+ return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(
427
+ rawMessage(cause),
428
+ );
429
+ }
430
+
431
+ function isParseError(cause: unknown): boolean {
432
+ const name = prop(cause, "name");
433
+ if (name === "SyntaxError" || name === "BuildMessage") return true;
434
+ if (prop(cause, "code") === "PARSE_ERROR") return true;
435
+ // Bun's build wrapper with its diagnostics already dropped — the shape every caller after the first
436
+ // sees, and the one `pithy doctor` renders. It proves a build produced diagnostics, so it is a parse
437
+ // error with no reason and no position rather than a config that threw. See core's `cause.ts`.
438
+ if (isBuildFailureWrapper(cause)) return true;
439
+ return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(
440
+ rawMessage(cause),
441
+ );
442
+ }
443
+
444
+ /**
445
+ * Choose the refusal's `action` **from** the failure rather than asserting one over it (#207).
446
+ *
447
+ * A `pithy.config.ts` that will not import used to get one sentence whatever went wrong: *install the
448
+ * project's dependencies, then check the config for errors*. For a missing dependency that is right. For
449
+ * a stray brace it is confidently wrong, and being confidently wrong is worse than saying nothing —
450
+ * an adopter runs `bun install`, nothing changes, and the parser's own message, which would have told
451
+ * them, was captured into `detail` and discarded one frame up. That is #172 recurring: a config that
452
+ * would not load naming the wrong cause, diagnosed wrong twice before anyone traced the import edge.
453
+ *
454
+ * So each cause gets the sentence that fits it, and the fourth gets none:
455
+ *
456
+ * - **unresolved-import** — the specifier is named, and `bun install` is right *here*, where it was earned.
457
+ * - **parse-error** — the parser's reason and position. It says installing will not help, because the
458
+ * adopter has read the opposite for as long as this refusal has existed.
459
+ * - **threw-on-load** — the config's own error, which is neither of the above.
460
+ * - **unknown** — no remedy. A wrong action is worse than no action, because it is followed.
461
+ *
462
+ * Exported because the runtime that ships (`bin` runs on Bun, whose `ResolveMessage`/`BuildMessage` are
463
+ * their own shapes) is not the runtime the suite runs on. Tested directly, it is tested for both.
464
+ *
465
+ * @param wrapped whatever the `try` caught, wrapper and all. {@link rootCause} opens it first — see #223:
466
+ * Bun wraps two or more build diagnostics in an `AggregateError`, and a stray brace cascades, so the
467
+ * wrapped shape is the *common* one. #207 fixed the bare case and this one classified worse than it.
468
+ */
469
+ export function classifyConfigLoadFailure(wrapped: unknown): ConfigLoadFailure {
470
+ // Bun hands `import()` failures over inside an `AggregateError`. Classify what is inside it.
471
+ const cause = rootCause(wrapped);
472
+
473
+ if (isUnresolvedImport(cause)) {
474
+ const specifier = unresolvedSpecifier(cause);
475
+ return {
476
+ kind: "unresolved-import",
477
+ action: specifier
478
+ ? `Nothing resolves "${specifier}". Install the project's dependencies (bun install), or correct that import.`
479
+ : "An import in the config does not resolve. Install the project's dependencies (bun install), then check its imports.",
480
+ };
481
+ }
482
+
483
+ if (isParseError(cause)) {
484
+ // Both of these can answer "nothing" — and one of the reasons they can is Bun's build wrapper, whose
485
+ // whole message is a count and a path. That suppression is core's, once, rather than written out here
486
+ // and in two other classifiers that each had to be found and patched (#223, #228).
487
+ const reason = safeReason(cause);
488
+ const at = failurePosition(cause);
489
+ const where = at ? ` Line ${at.line}, column ${at.column}.` : "";
490
+ return {
491
+ kind: "parse-error",
492
+ action: `The config does not parse${reason ? `: ${reason}` : ""}.${where} Fix the file — installing dependencies will not help.`,
493
+ };
494
+ }
495
+
496
+ // Anything that carries a message, whether or not it extends `Error` — the config's own throw.
497
+ if (causeMessage(cause) !== undefined) {
498
+ const reason = safeReason(cause);
499
+ return {
500
+ kind: "threw-on-load",
501
+ action: reason
502
+ ? `The config threw while loading: ${reason}. Fix that in the config.`
503
+ : "The config threw while loading. Run the file directly to see what it throws.",
504
+ };
505
+ }
506
+
507
+ return { kind: "unknown", action: "Check pithy.config.ts. Run the file directly to see how it fails." };
508
+ }
509
+
510
+ /**
511
+ * Import a config the current process has **just written**, and get what is on disk rather than what was
512
+ * imported before the write.
513
+ *
514
+ * A module cache keyed on the path is correct almost everywhere and wrong in exactly one place: a command
515
+ * that edits a `pithy.config.ts` and then has to read the result. `pithy add` is that command — it
516
+ * resolves the target Worker (importing its config) before wiring, and its closing migrate imports the
517
+ * config again to build the registry. The second import returned the *pre-wiring* module, so the registry
518
+ * never contained the capability that had just been added and `add` applied none of its migrations while
519
+ * reporting a clean run. `pithy add auth` left a Worker that booted and answered 500 on every auth route,
520
+ * because the tables were not there (#273).
521
+ *
522
+ * **Only a different file busts it.** Measured on Bun 1.3: a `?t=…` query on the file URL does not, and
523
+ * neither does the same file reached by a differently-spelled path. So this imports a copy, beside the
524
+ * original — where the config's own relative imports and `import.meta.dirname` still resolve to the same
525
+ * directory — and removes it. Dot-prefixed and uniquely named, so a crashed run leaves nothing a tool
526
+ * collects and two concurrent runs cannot collide.
527
+ */
528
+ async function importFreshCopy(path: string): Promise<{ default?: unknown }> {
529
+ const copy = join(dirname(path), `.pithy.reload.${randomUUID()}.ts`);
530
+ await copyFile(path, copy);
531
+ try {
532
+ return (await import(pathToFileURL(copy).href)) as { default?: unknown };
533
+ } finally {
534
+ await rm(copy, { force: true });
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Import a `pithy.config.ts` and return its default export, with an error that names its own cause.
540
+ *
541
+ * `fresh` is for a caller that has written the file in this process — see {@link importFreshCopy}. Every
542
+ * other caller takes the cache, because a config imported twice in one command should be one module.
543
+ */
544
+ async function importConfig(path: string, missing: () => never, fresh = false): Promise<unknown> {
545
+ try {
546
+ await access(path);
547
+ } catch {
548
+ missing();
549
+ }
550
+
551
+ let module: { default?: unknown };
552
+ try {
553
+ module = fresh ? await importFreshCopy(path) : ((await import(pathToFileURL(path).href)) as { default?: unknown });
554
+ } catch (cause) {
555
+ // The file is present but would not import. Which of the three ways it failed decides what to tell
556
+ // the adopter — see {@link classifyConfigLoadFailure}. The raw cause still goes to `detail` and stops
557
+ // there: the CLI renderer prints `message` and `action` only, and the HTTP codec strips `detail`.
558
+ // That boundary is unchanged. What changed is that the part of the cause an adopter can act on now
559
+ // reaches them through `action`, in a form that carries no path, no source line, and no stack.
560
+ const { kind, action } = classifyConfigLoadFailure(cause);
561
+ throw new InternalError({
562
+ message: `Could not load ${path}.`,
563
+ action,
564
+ detail: `${kind}: ${causeMessage(cause) ?? String(cause)}`,
565
+ });
566
+ }
567
+ return module.default;
568
+ }
569
+
570
+ /** Options for {@link loadWorkerConfig}. */
571
+ export interface LoadWorkerConfigOptions {
572
+ /**
573
+ * Re-read the file rather than take the module cache. **Only for a caller that has written this config
574
+ * in this process** — `pithy add`'s closing migrate is the one that must, and the one whose absence of
575
+ * it meant a capability's migrations silently never ran (see `importFreshCopy`).
576
+ */
577
+ fresh?: boolean;
578
+ }
579
+
580
+ /**
581
+ * Load one Worker's `apps/<name>/pithy.config.ts` — the capabilities that Worker composes. Imported live
582
+ * (the config is code), so this runs under a TS-capable runtime; Phase 0 ships the bin on Bun.
583
+ */
584
+ export async function loadWorkerConfig(workerDir: string, options?: LoadWorkerConfigOptions): Promise<WorkerConfig> {
585
+ const path = join(workerDir, "pithy.config.ts");
586
+ const value = await importConfig(
587
+ path,
588
+ () => {
589
+ throw new NotFoundError({
590
+ message: `No pithy.config.ts in ${workerDir}.`,
591
+ action: "Every worker under apps/ needs one. pithy worker add creates it.",
592
+ });
593
+ },
594
+ options?.fresh === true,
595
+ );
596
+ if (!isWorkerConfig(value)) {
597
+ throw new InternalError({
598
+ message: `${path} doesn't default-export a worker config.`,
599
+ action: "Export default { capabilities, app }.",
600
+ });
601
+ }
602
+ return value;
603
+ }
604
+
605
+ /**
606
+ * Load the **root** `pithy.config.ts` — the project's identity and policy. Capabilities are not here; they
607
+ * live per Worker ({@link loadWorkerConfig}).
608
+ */
609
+ export async function loadProject(projectDir: string): Promise<ProjectConfig> {
610
+ const path = join(projectDir, "pithy.config.ts");
611
+ const value = await importConfig(path, () => {
612
+ throw new NotFoundError({
613
+ message: "No pithy.config.ts here.",
614
+ action: "Run from a Pithy project. pithy init creates one.",
615
+ });
616
+ });
617
+ if (!isProjectConfig(value)) {
618
+ throw new InternalError({
619
+ message: "pithy.config.ts doesn't default-export a config.",
620
+ action: "Export default { name }.",
621
+ });
622
+ }
623
+ // Validated on every load, so a name that would not survive `CloudflareAccountName` refuses the
624
+ // command that read the config rather than whichever later call happened to build a path out of it.
625
+ // The value is *returned*, never published: an ambient account is what made six call sites resolve
626
+ // credentials before anything had established which account they were for.
627
+ loadProjectCloudflare(value);
628
+ // Same rule, same reason: an environment name reaches Cloudflare resource names verbatim, and a
629
+ // provisioned project cannot be renamed — so a declaration that would not survive the naming rule
630
+ // refuses the command that read the config, rather than the later call that composed a name from it.
631
+ loadProjectEnvironments(value);
632
+ return value;
633
+ }
634
+
635
+ /**
636
+ * The Cloudflare account a project belongs to, loaded from its root config — **the one way a command
637
+ * gets an account to resolve credentials for.**
638
+ *
639
+ * `cloudflareEnv` takes the account as an argument rather than reading an ambient, and this is what
640
+ * every caller passes it. Loading the project is the only way to learn the answer, so the two are one
641
+ * call here: a command cannot resolve credentials "before" the project any more than it can call this
642
+ * function without awaiting it.
643
+ *
644
+ * Throws what {@link loadProject} throws when there is no config here — a command that is inside a
645
+ * project and cannot read its config has a worse problem than which account it is for. A caller that
646
+ * legitimately has no project (`pithy init` before the scaffold exists) passes `null` instead of asking.
647
+ */
648
+ export async function projectCloudflareAccount(projectDir: string): Promise<ProjectCloudflare | null> {
649
+ return loadProjectCloudflare(await loadProject(projectDir)) ?? null;
650
+ }
651
+
652
+ /** Every capability one Worker composes, in order: libraries first, its app last. */
653
+ export function allCapabilities(config: WorkerConfig): Capability[] {
654
+ return config.app ? [...config.capabilities, config.app] : [...config.capabilities];
655
+ }
656
+
657
+ /**
658
+ * The project name, leniently guessed. Prefers the explicit `pithy.config.ts` `name`, then the first
659
+ * discovered worker's `wrangler.jsonc` name, then the project directory's own name — always normalized
660
+ * to hyphenated-lowercase. The fallbacks are **not stable**: `discoverWorkers` sorts alphabetically, so
661
+ * adding an app that sorts earlier changes the guess, and the directory basename differs between a
662
+ * worktree checkout and a normal clone. Fine for a cosmetic default; never use this where the name
663
+ * feeds a naming convention another command must reproduce later — use {@link requireProjectName} there.
664
+ */
665
+ export async function resolveProjectName(config: ProjectConfig, projectDir: string): Promise<string> {
666
+ if (config.name) return kebab(config.name);
667
+ const [worker] = await discoverWorkers(projectDir);
668
+ if (worker) return kebab(worker.name);
669
+ return kebab(basename(projectDir));
670
+ }
671
+
672
+ /**
673
+ * The project name for the `pithy feature` resource-naming convention — the FIRST SEGMENT of every
674
+ * Cloudflare resource name (`<project>-f<issue>-<slug>-<binding>-<kind>`) and the only key
675
+ * `pithy feature destroy` has to find and delete them again. Unlike {@link resolveProjectName}, this
676
+ * never guesses: it requires an explicit `pithy.config.ts` `name`, because any fallback that can differ
677
+ * between machines or checkouts (an alphabetically-first worker, a worktree's directory basename) would
678
+ * make teardown recompute names that match nothing, delete nothing, and exit 0 — a silent resource leak.
679
+ * Throws an actionable `ValidationError` when `name` is absent.
680
+ *
681
+ * It also holds the name to `assertValidProjectName`, and that is the *second* half of the same guard.
682
+ * `scaffoldProject` keeps a bad name from being created; this keeps an already-created one from getting
683
+ * anywhere. Cloudflare's namespaces disagree about what a legal project segment is — D1, KV, and R2 take
684
+ * a digit-leading name, Worker scripts and Workflows refuse it — so without this check `pithy add` and
685
+ * `pithy migrate` provision real resources and only the first host-worker deploy fails, leaving a
686
+ * half-provisioned project whose only documented fix orphans everything already created. Every command
687
+ * resolves the project through here, so every command refuses on the first one instead.
688
+ */
689
+ export function requireProjectName(config: ProjectConfig): string {
690
+ if (!config.name) {
691
+ throw new ValidationError({
692
+ message: "pithy.config.ts has no `name`.",
693
+ action:
694
+ "Set `name` in pithy.config.ts. Every feature resource name — and pithy feature destroy's ability to find and delete it later — derives from this name, so it must stay stable across machines and checkouts.",
695
+ });
696
+ }
697
+ assertValidProjectName(config.name);
698
+ // `kebab` is core's, imported rather than reimplemented: a project name has to normalize identically
699
+ // in every command that composes a resource name, and a second copy here would drift.
700
+ return kebab(config.name);
701
+ }