@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,584 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
6
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { readMigrationOwner } from "@pithy-sh/core/src/migrations/owner";
8
+ import { assertValidProjectName, isValidProjectName, kebab } from "@pithy-sh/core/src/naming/resource";
9
+ import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
10
+ import { loadProject, loadProjectCloudflare, type ProjectConfig } from "../project/config";
11
+ import { discoverWorkers, type WorkerTarget } from "../project/workers";
12
+ import { readWranglerConfig } from "../project/wrangler";
13
+
14
+ /**
15
+ * Whether the project name in the root `pithy.config.ts` still matches the names this project's resources
16
+ * were provisioned under.
17
+ *
18
+ * **Why this is a health check.** Every provisioned name now leads with the project
19
+ * (`<project>-<env>-<thing>`), and teardown *recomputes* those names rather than scanning a prefix. So one
20
+ * edit to `name` orphans everything: `pithy feature destroy` computes names that no longer exist, finds
21
+ * nothing, and exits 0 while the real D1, KV, buckets, and Worker scripts keep running and keep billing.
22
+ * Provisioning quietly builds a parallel set beside them. Nothing else in the toolchain notices, which is
23
+ * exactly why it belongs in the command whose job is noticing.
24
+ *
25
+ * **A rename is never inferred from a name's shape.** `<app>-<env>-<resource>` is the ordinary Cloudflare
26
+ * naming convention, so *every* database an adopter named that way before they had ever heard of Pithy has
27
+ * Pithy's shape. Reading a project segment off one name and calling the difference a rename told adopters
28
+ * on the migrate-an-existing-Worker-into-Pithy path — the adoption path — that their live production
29
+ * database was an orphan, and advised deleting it. Shape narrows the candidates. It establishes nothing.
30
+ *
31
+ * Two kinds of evidence do, and they are ranked:
32
+ *
33
+ * 1. **Proof — Pithy's own owner stamp.** `pithy migrate` (and every other writing command) records the
34
+ * project in `pithy_migrations_owner` on the database itself. A live database whose stamp names a
35
+ * *different* project than the config is proof: Pithy made that database, under that name, for this
36
+ * codebase. Nothing about it is inferred. Only proof reaches `orphaned`, and even then the remedy never
37
+ * says "delete" — the stamp proves one database's provenance, not the whole list's.
38
+ * 2. **Deduction — the wiring contradicts the config wholesale.** The stamp only covers databases Pithy has
39
+ * migrated, so it narrows rather than replaces the local check. The project name is **one string**: a
40
+ * rename moves every derived name at once, so it cannot leave some names on the new name. That makes the
41
+ * signature checkable from files alone — *every* declared name leads with one and the same other project,
42
+ * and the configured name appears nowhere. One odd name out is a resource the adopter brought with them;
43
+ * the whole wiring disagreeing is the config and the wiring contradicting each other, which is a fault
44
+ * whoever caused it. That is {@link wholesaleRename}, and it is what `drifted` means now.
45
+ *
46
+ * The account is still asked whether a name is live, but existence is *not* evidence of ownership: a
47
+ * database that exists under a name the adopter chose is just their database. It only colors the report.
48
+ */
49
+ export type ProjectNameState =
50
+ /**
51
+ * The root `pithy.config.ts` loaded and carries no `name`. Nothing to reconcile; never fails the exit.
52
+ *
53
+ * **Only that case.** It used to cover a missing config file too, and the one message the two shared —
54
+ * "add `name` to pithy.config.ts" — was written for this branch and wrong for the other: it named a key
55
+ * in a file that did not exist, in a directory with no project to name. A config with no `name` is now
56
+ * the rare hand-edited case (`pithy init` always writes one), and it is the only case this answers.
57
+ * When the config cannot be read at all, {@link checkProjectName} returns `null` instead.
58
+ */
59
+ | "unconfigured"
60
+ /**
61
+ * A `name` is set and no Cloudflare namespace could carry it — {@link isValidProjectName} refuses it.
62
+ *
63
+ * Distinct from `unconfigured` on purpose, and it **fails the exit**. Collapsing the two reported an
64
+ * illegal name as "not set", which told the operator to add a name they had already added while
65
+ * `pithy add`, `pithy migrate`, `pithy secrets provision`, and `pithy token mint` were all hard-failing
66
+ * on that same project — and left a CI gate on `pithy doctor` green. Unlike `unconfigured` and
67
+ * `could-not-check` this is a positively established fault, which is the standard the failing states
68
+ * are held to.
69
+ */
70
+ | "invalid"
71
+ /** Every provisioned name this project's wiring declares leads with the configured project. */
72
+ | "ok"
73
+ /** The wiring could not be read (no worker declares a name, or a `wrangler.jsonc` would not parse). Never fails the exit. */
74
+ | "could-not-check"
75
+ /**
76
+ * The wiring contradicts the config wholesale — every declared name leads with one other project and the
77
+ * configured name appears nowhere ({@link wholesaleRename}). Established from local files alone, and it
78
+ * claims nothing about who owns what: the two sources of truth in this repo disagree, and one is wrong.
79
+ */
80
+ | "drifted"
81
+ /**
82
+ * Proven: a live database carries Pithy's own `pithy_migrations_owner` stamp naming a project other than
83
+ * the configured one. Pithy made it, under that name, and the configured name will never find it again.
84
+ */
85
+ | "orphaned";
86
+
87
+ /** One resource whose declared name leads with a project segment that is not the configured one. */
88
+ export interface MisnamedResource {
89
+ /** The resource name exactly as `wrangler.jsonc` declares it. */
90
+ name: string;
91
+ /** The project segment that name leads with — a *candidate* old name until something proves it. */
92
+ project: string;
93
+ /** The kind of Cloudflare resource: the two whose names live in `wrangler.jsonc`. */
94
+ kind: "d1" | "r2";
95
+ /** The Worker whose `wrangler.jsonc` declares it, so the fix has an address. */
96
+ worker: string;
97
+ /** The environment stanza it sits in — `dev` for the top-level one. */
98
+ env: string;
99
+ /** The Worker env binding it is bound to. */
100
+ binding: string;
101
+ /**
102
+ * Whether the account holds a resource by this name — `null` when the account was not consulted or would
103
+ * not answer. Existence is context, never evidence of ownership: a live database under a name the adopter
104
+ * chose is simply theirs. Tri-state on purpose, because "I did not find out" is not "it is not there".
105
+ */
106
+ provisioned: boolean | null;
107
+ /**
108
+ * The project recorded in this database's `pithy_migrations_owner` stamp, or `null` when there is none to
109
+ * read (an R2 bucket, a database Pithy never migrated, an unreachable account). This is the only field
110
+ * that ever *proves* ownership — `owner === project` means Pithy made this resource under that name.
111
+ */
112
+ owner: string | null;
113
+ }
114
+
115
+ /** What `doctor` learned about the configured project name. */
116
+ export interface ProjectNameCheck {
117
+ state: ProjectNameState;
118
+ /**
119
+ * The configured project name, or `null` when there is none to report. Kebabed — the form every
120
+ * resource name is composed from — except under `invalid`, where it is the raw configured value: an
121
+ * illegal name is never composed with, and the operator has to recognize the string they typed.
122
+ */
123
+ project: string | null;
124
+ /** Every declared resource name leading with a different project segment. Empty unless drifted or orphaned. */
125
+ misnamed: MisnamedResource[];
126
+ }
127
+
128
+ /** One resource name a Worker's `wrangler.jsonc` declares, with the environment and binding it belongs to. */
129
+ export interface DeclaredResource {
130
+ /** The declared `database_name` / `bucket_name`. */
131
+ name: string;
132
+ /** Which kind of entry it came from. */
133
+ kind: "d1" | "r2";
134
+ /** The Worker that declares it. */
135
+ worker: string;
136
+ /** The environment stanza — `dev` for the top-level one. */
137
+ env: string;
138
+ /** The binding the entry is bound to. */
139
+ binding: string;
140
+ }
141
+
142
+ /** The `wrangler.jsonc` keys this reads. Only the two entry kinds that carry a resource *name*. */
143
+ interface NamedBindings {
144
+ d1_databases?: { binding?: string; database_name?: string }[];
145
+ r2_buckets?: { binding?: string; bucket_name?: string }[];
146
+ env?: Record<string, NamedBindings | undefined>;
147
+ }
148
+
149
+ /** Every environment's stanza: the top-level one (the dev environment) plus each `env.<name>`. */
150
+ function envStanzas(config: NamedBindings): { env: string; stanza: NamedBindings }[] {
151
+ const list: { env: string; stanza: NamedBindings }[] = [{ env: "dev", stanza: config }];
152
+ for (const [env, stanza] of Object.entries(config.env ?? {})) {
153
+ if (stanza) list.push({ env, stanza });
154
+ }
155
+ return list;
156
+ }
157
+
158
+ /** Pull the named entries out of one stanza. An entry with no binding or no name declares nothing. */
159
+ function stanzaResources(stanza: NamedBindings, worker: string, env: string): DeclaredResource[] {
160
+ const found: DeclaredResource[] = [];
161
+ for (const entry of stanza.d1_databases ?? []) {
162
+ if (entry.binding && entry.database_name) {
163
+ found.push({ name: entry.database_name, kind: "d1", worker, env, binding: entry.binding });
164
+ }
165
+ }
166
+ for (const entry of stanza.r2_buckets ?? []) {
167
+ if (entry.binding && entry.bucket_name) {
168
+ found.push({ name: entry.bucket_name, kind: "r2", worker, env, binding: entry.binding });
169
+ }
170
+ }
171
+ return found;
172
+ }
173
+
174
+ /** What {@link declaredResources} found, and whether it managed to read everything it tried to. */
175
+ export interface DeclaredResources {
176
+ /** Every named D1 database and R2 bucket declared across every Worker and every environment. */
177
+ resources: DeclaredResource[];
178
+ /**
179
+ * True when the wiring could not be read in full: the Worker set would not enumerate (a
180
+ * `pithy.worker.jsonc` that will not parse), or at least one Worker's `wrangler.jsonc` would not.
181
+ */
182
+ unreadable: boolean;
183
+ }
184
+
185
+ /**
186
+ * Every resource name this project's Workers declare, across every environment.
187
+ *
188
+ * Only D1 and R2 appear, because they are the only entries wrangler gives a name field
189
+ * (`database_name`, `bucket_name`). A `kv_namespaces` entry has no title, so a KV namespace's name lives
190
+ * only in the account and there is nothing local to compare it against.
191
+ *
192
+ * An unreadable `wrangler.jsonc` is recorded rather than thrown: the other Workers still have something
193
+ * to say, and a partial answer plus "I could not read all of it" beats a diagnostic that gives up. A
194
+ * Worker that has **no** `wrangler.jsonc` is skipped outright and is not unreadable — `apps/` also holds
195
+ * non-Worker processes (a Vite frontend joins the dev set through `pithy.worker.jsonc` alone), and
196
+ * reporting a healthy project as unchecked because of one is worse than saying nothing about it.
197
+ *
198
+ * Discovery itself gets the same treatment. `discoverWorkers` parses every `pithy.worker.jsonc` and
199
+ * **throws** on one that will not parse or will not validate — correct for `dev` and `deploy`, where a
200
+ * typo'd manifest must stop the run, and exactly wrong here: `doctor` exists to work in the broken
201
+ * project. So a failed enumeration is the same answer as an unreadable Worker, just for all of them.
202
+ */
203
+ export async function declaredResources(projectDir: string): Promise<DeclaredResources> {
204
+ let workers: WorkerTarget[];
205
+ try {
206
+ workers = await discoverWorkers(projectDir);
207
+ } catch {
208
+ return { resources: [], unreadable: true };
209
+ }
210
+ const resources: DeclaredResource[] = [];
211
+ let unreadable = false;
212
+ for (const worker of workers) {
213
+ if (!worker.hasWrangler) continue;
214
+ let config: NamedBindings;
215
+ try {
216
+ config = (await readWranglerConfig(worker.dir)) as NamedBindings;
217
+ } catch {
218
+ unreadable = true;
219
+ continue;
220
+ }
221
+ for (const { env, stanza } of envStanzas(config)) {
222
+ resources.push(...stanzaResources(stanza, worker.name, env));
223
+ }
224
+ }
225
+ return { resources, unreadable };
226
+ }
227
+
228
+ /**
229
+ * The project segment of a declared name, when that name is one Pithy's rule *could* have produced for this
230
+ * binding in this environment — otherwise `null`.
231
+ *
232
+ * The suffix is the discriminator: `<env>-<binding>`, kebab-cased, is exactly what the naming rule puts
233
+ * after the project, so a name ending in it has Pithy's shape and everything before it reads as a project
234
+ * segment. **Shape is a filter, never a finding.** `<app>-<env>-<resource>` is also the ordinary Cloudflare
235
+ * convention every adopter already uses, so this says only "that name is a candidate" — never "Pithy made
236
+ * it". A caller that treats a non-null return as a rename is the bug this comment used to describe as the
237
+ * fix. See {@link wholesaleRename} and the owner stamp for what actually establishes one.
238
+ */
239
+ export function projectSegmentOf(name: string, env: string, binding: string): string | null {
240
+ const suffix = `-${kebab(env)}-${kebab(binding)}`;
241
+ if (!name.endsWith(suffix)) return null;
242
+ const project = name.slice(0, -suffix.length);
243
+ return project.length > 0 ? project : null;
244
+ }
245
+
246
+ /**
247
+ * Whether a declared name leads with this project — the whole leading segment, and nothing about the rest.
248
+ *
249
+ * The counterpart to {@link projectSegmentOf}, and deliberately not it. That function answers "could Pithy's
250
+ * rule have produced this exact name for this binding", which is the right filter for picking *candidates*
251
+ * and the wrong one for asking whether the configured project appears at all: **several Pithy-provisioned
252
+ * names put the capability in the thing slot rather than the binding.**
253
+ * `resourceNames(project).env(env).r2("storage")` composes `acme-prod-storage` for the `STORAGE_BUCKET`
254
+ * binding; media and support do the same. Asked through the strict tail, a correctly named bucket Pithy made
255
+ * itself reads as no evidence at all, and {@link wholesaleRename} fires on the adoption path it exists to
256
+ * protect.
257
+ *
258
+ * A whole segment, not a prefix: `acmecorp-prod-db` does not lead with `acme`.
259
+ */
260
+ function leadsWithProject(name: string, project: string): boolean {
261
+ return name === project || name.startsWith(`${project}-`);
262
+ }
263
+
264
+ /** A candidate before the account has said anything about it: shape only, no evidence attached. */
265
+ export type MisnamedCandidate = Omit<MisnamedResource, "provisioned" | "owner">;
266
+
267
+ /**
268
+ * Every declared resource whose name has Pithy's shape but leads with a project segment that is not ours.
269
+ *
270
+ * These are **candidates**, not findings. Most projects with one legacy database have exactly one, and it is
271
+ * not a rename. {@link checkProjectName} decides.
272
+ */
273
+ export function misnamedResources(project: string, declared: DeclaredResource[]): MisnamedCandidate[] {
274
+ const ours = kebab(project);
275
+ const found: MisnamedCandidate[] = [];
276
+ for (const resource of declared) {
277
+ const segment = projectSegmentOf(resource.name, resource.env, resource.binding);
278
+ if (segment === null || segment === ours) continue;
279
+ found.push({
280
+ name: resource.name,
281
+ project: segment,
282
+ kind: resource.kind,
283
+ worker: resource.worker,
284
+ env: resource.env,
285
+ binding: resource.binding,
286
+ });
287
+ }
288
+ return found;
289
+ }
290
+
291
+ /**
292
+ * Whether the wiring contradicts the config *wholesale* — the one shape a rename can leave.
293
+ *
294
+ * The project name is a single string. Change it and every name derived from it moves together, so a rename
295
+ * cannot leave some declared names on the new name and some on the old. Three conditions follow, and all
296
+ * three are checked against local files only:
297
+ *
298
+ * - **The configured name appears nowhere.** One resource declared under the configured project settles it:
299
+ * this project provisions under its current name, so the odd names out are things brought in from
300
+ * somewhere else, not stragglers. (A rename followed by re-provisioning does produce a mix — that case is
301
+ * the owner stamp's, which is the only thing that can tell the two apart.) Asked through
302
+ * {@link leadsWithProject} rather than the strict shape: the question is whether the project *leads* any
303
+ * declared name, and a bucket named `<project>-<env>-storage` for a `STORAGE_BUCKET` binding is the
304
+ * configured name appearing, however little it looks like `<env>-<binding>`.
305
+ * - **Exactly one foreign segment.** Two would be two renames, which one string cannot have done.
306
+ * - **More than one distinct name carries it.** This is the concession, and it is deliberate: a single
307
+ * `<something>-<env>-<binding>` is indistinguishable from the Cloudflare convention, and calling one name
308
+ * a rename is precisely the false positive that reported an adopter's production database as an orphan.
309
+ * Deduped by name, because two Workers sharing a binding declare one resource twice, not two resources.
310
+ */
311
+ export function wholesaleRename(
312
+ project: string,
313
+ declared: DeclaredResource[],
314
+ candidates: MisnamedCandidate[],
315
+ ): boolean {
316
+ if (candidates.length === 0) return false;
317
+ const ours = kebab(project);
318
+ for (const resource of declared) {
319
+ if (leadsWithProject(resource.name, ours)) return false;
320
+ }
321
+ if (new Set(candidates.map((entry) => entry.project)).size !== 1) return false;
322
+ return new Set(candidates.map((entry) => entry.name)).size >= 2;
323
+ }
324
+
325
+ /** What the account could be made to say about one candidate name. Absent from the map means "unknown". */
326
+ export interface AccountEvidence {
327
+ /** A resource by this exact name exists in the account. Context for the report, never proof of ownership. */
328
+ exists: boolean;
329
+ /** The project in this database's `pithy_migrations_owner` stamp — the one authoritative ownership record. */
330
+ owner: string | null;
331
+ }
332
+
333
+ /**
334
+ * The account seam: per-name evidence for the candidates, keyed by resource name. A name with no entry was
335
+ * never established either way (no credentials, an unreachable account, a listing that was refused), which
336
+ * is reported as unknown rather than as absence.
337
+ *
338
+ * **The account is on the seam, not behind it (#234).** A stub that took only the candidates would let the
339
+ * real implementation keep a defaulted account and no test would ever notice, which is exactly the state
340
+ * this replaced.
341
+ */
342
+ export type AccountProbe = (
343
+ candidates: MisnamedCandidate[],
344
+ account: CloudflareAccountSelection | null,
345
+ ) => Promise<Map<string, AccountEvidence>>;
346
+
347
+ /**
348
+ * The recorded owner of a database, read through the one read-only seam `@pithy-sh/core` exposes.
349
+ *
350
+ * Never throws and never invents: an unstamped database, a database whose bookkeeping table has never been
351
+ * created, or a token without query permission all come back `null` — absence of proof, which keeps the
352
+ * verdict on the local deduction instead of escalating it.
353
+ */
354
+ async function ownerStampOf(clients: CloudflareClients, databaseId: string): Promise<string | null> {
355
+ try {
356
+ const owner = await readMigrationOwner(clients.d1(databaseId) as unknown as D1Database);
357
+ return owner?.project ?? null;
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Ask the account about the candidates: does a resource by that name exist, and — for D1, the only kind
365
+ * that records it — which project stamped it. Never throws; a failed listing simply leaves those names out
366
+ * of the map.
367
+ */
368
+ export async function probeAccountEvidence(
369
+ candidates: MisnamedCandidate[],
370
+ /**
371
+ * The Cloudflare account this project belongs to, from its own root `pithy.config.ts`. The verdict this
372
+ * function can reach — `orphaned`, "a live database is not yours" — is one no adopter should ever read
373
+ * off the wrong account (#206).
374
+ *
375
+ * **Required, and ahead of the `connect` seam, because it used to be `= null` behind it (#234).** A
376
+ * default parameter is the quietest of the three shapes an optional account can take: the `null` is
377
+ * written at the declaration, so it reads like a decision somebody made, while every call site says
378
+ * nothing at all. And the failure it produces is not a failed call — it is a *sentence*. A project on
379
+ * `cloudflare.accountName` got the default account's credentials here, found no database by its own
380
+ * name in a tenant that was never asked, and the deduction it fed says the adopter's live production
381
+ * database belongs to someone else. Wrong credentials that refuse are a bad afternoon; wrong
382
+ * credentials that answer confidently are the bug this parameter exists to make unwritable.
383
+ */
384
+ account: CloudflareAccountSelection | null,
385
+ /**
386
+ * How the account is reached. Injectable for the same reason `probeAccount` is one level up, but a level
387
+ * lower: stubbing `probeAccount` replaces this whole function, so the branching *inside* it — which kinds
388
+ * get probed, how a missing database differs from an unreadable listing, whether a stamp is read at all —
389
+ * had no test at all. It is the only path that can reach the `orphaned` verdict, and that verdict tells
390
+ * an adopter a live database is not theirs.
391
+ */
392
+ connect: (credentials: { accountId: string; apiToken: string }) => CloudflareClients = (credentials) =>
393
+ new CloudflareClients(credentials),
394
+ ): Promise<Map<string, AccountEvidence>> {
395
+ const evidence = new Map<string, AccountEvidence>();
396
+ // **A pin the credentials contradict ends the probe, before the network.** `cloudflareEnv` throws on a
397
+ // mismatch, which is right everywhere it is a command's own resolution — but this is a diagnostic, and a
398
+ // `pithy doctor` that exits on the fault it exists to report tells nobody anything. So the refusal is
399
+ // kept and the throw is not: there is no account this run is entitled to ask, so it asks none and
400
+ // establishes nothing, which leaves the verdict on the local deduction and out of reach of `orphaned`.
401
+ // The mismatch itself is already a line of this same report — `Cloudflare:`, `account_mismatch` — and
402
+ // one fact belongs in one line, which is the rule the rest of this file is written to.
403
+ let vars: Record<string, string>;
404
+ try {
405
+ vars = cloudflareEnv({ account });
406
+ } catch {
407
+ return evidence;
408
+ }
409
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
410
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
411
+ if (!accountId || !apiToken) return evidence;
412
+
413
+ const clients = connect({ accountId, apiToken });
414
+ const databases = new Set(candidates.filter((entry) => entry.kind === "d1").map((entry) => entry.name));
415
+ const buckets = new Set(candidates.filter((entry) => entry.kind === "r2").map((entry) => entry.name));
416
+
417
+ if (databases.size > 0) {
418
+ try {
419
+ const live = new Map((await clients.d1Provisioner().listDatabases()).map((db) => [db.name, db.uuid]));
420
+ for (const name of databases) {
421
+ const id = live.get(name);
422
+ evidence.set(name, { exists: id !== undefined, owner: id ? await ownerStampOf(clients, id) : null });
423
+ }
424
+ } catch {
425
+ // Unreachable or unauthorized. The names stay unknown — the verdict falls back to the local deduction.
426
+ }
427
+ }
428
+ if (buckets.size > 0) {
429
+ try {
430
+ const live = new Set((await clients.r2Provisioner().listBuckets()).map((bucket) => bucket.name));
431
+ for (const name of buckets) evidence.set(name, { exists: live.has(name), owner: null });
432
+ } catch {
433
+ // Same. R2 carries no stamp either way, so a bucket is only ever context.
434
+ }
435
+ }
436
+ return evidence;
437
+ }
438
+
439
+ /** Injectable seams for {@link checkProjectName} — the account probe, so a unit test never reaches one. */
440
+ export interface CheckProjectNameOptions {
441
+ /** Account-evidence seam; defaults to {@link probeAccountEvidence}. */
442
+ probeAccount?: AccountProbe;
443
+ }
444
+
445
+ /**
446
+ * Probe the configured project name against what this project's wiring declares and what the account
447
+ * holds.
448
+ *
449
+ * Never throws: a diagnostic command has to keep working in exactly the broken environment it exists to
450
+ * diagnose, so every failure becomes a state rather than an exception. The account is consulted only when
451
+ * there is a candidate to ask about — a healthy project pays for no network call at all.
452
+ *
453
+ * **The verdict is evidence-ranked, and shape is not evidence.** Proof first: a candidate database whose
454
+ * `pithy_migrations_owner` stamp names its own segment was made by Pithy under that name, which is
455
+ * `orphaned` and is the only state that says a resource is this project's. Then the local deduction: a
456
+ * {@link wholesaleRename}, which is `drifted` and says only that the config and the wiring contradict each
457
+ * other. Anything less — one legacy name, a mix of names, two unrelated foreign projects — is `ok`. It has
458
+ * to be: the adopter who migrates an existing Worker in has all three, and none of them is a rename.
459
+ *
460
+ * **`requireProjectName`'s two refusals are taken apart rather than caught together.** It throws both for
461
+ * an absent `name` and for a present-but-illegal one, and one `catch` around it turned the second into the
462
+ * first — an illegal name reported as "not set", exiting 0, in the one command whose job is to name that
463
+ * problem. So the config is read directly and the two are branched, through the same seams
464
+ * `requireProjectName` itself uses ({@link isValidProjectName}, {@link kebab}) so no second rule exists here
465
+ * to drift from it.
466
+ *
467
+ * **`null` means the question does not arise here.** There is a configured project name to check only if
468
+ * the root `pithy.config.ts` could be read; when it could not — absent, or present and unloadable — this
469
+ * declines to answer rather than inventing a verdict. Its caller already reports *which* of the two it was
470
+ * (`doctor`'s `Project:` block: "no pithy.config.ts here", or "could not load — …"), and a second line
471
+ * restating that fact in different words is how the message this replaced came to advise adding a key to a
472
+ * file that did not exist. One fact, one line, in the block whose job it is.
473
+ */
474
+ export async function checkProjectName(
475
+ projectDir: string,
476
+ options: CheckProjectNameOptions = {},
477
+ ): Promise<ProjectNameCheck | null> {
478
+ let config: ProjectConfig;
479
+ try {
480
+ config = await loadProject(projectDir);
481
+ } catch {
482
+ return null;
483
+ }
484
+ const configured = config.name;
485
+ if (!configured) return { state: "unconfigured", project: null, misnamed: [] };
486
+ if (!isValidProjectName(configured)) return { state: "invalid", project: configured, misnamed: [] };
487
+ const project = kebab(configured);
488
+
489
+ const { resources, unreadable } = await declaredResources(projectDir);
490
+ const nothingToSay: ProjectNameCheck = {
491
+ state: unreadable ? "could-not-check" : "ok",
492
+ project,
493
+ misnamed: [],
494
+ };
495
+
496
+ const candidates = misnamedResources(project, resources);
497
+ if (candidates.length === 0) return nothingToSay;
498
+
499
+ // The account comes from the config already loaded above — the same value `projectCloudflareAccount`
500
+ // returns, without a second import of the adopter's `pithy.config.ts`. A project naming none passes
501
+ // `null`, which is a claim about this project rather than an omission (#234).
502
+ const account = loadProjectCloudflare(config) ?? null;
503
+ const evidence = await (options.probeAccount ?? probeAccountEvidence)(candidates, account);
504
+ const misnamed: MisnamedResource[] = candidates.map((entry) => {
505
+ const found = evidence.get(entry.name);
506
+ return { ...entry, provisioned: found ? found.exists : null, owner: found?.owner ?? null };
507
+ });
508
+
509
+ // Proof. Only a stamp that names the candidate's own segment says Pithy made it under that name, and
510
+ // only the segments that were proven are reported — a second foreign project in the same wiring is not
511
+ // implicated by the first one's stamp.
512
+ const proven = new Set(misnamed.filter((entry) => entry.owner === entry.project).map((entry) => entry.project));
513
+ if (proven.size > 0) {
514
+ return { state: "orphaned", project, misnamed: misnamed.filter((entry) => proven.has(entry.project)) };
515
+ }
516
+
517
+ // Deduction. Nothing here claims a resource is ours; it says the two local sources of truth disagree.
518
+ // Only on a *complete* read of the wiring: the deduction turns on the configured name appearing nowhere,
519
+ // and a `wrangler.jsonc` that would not parse is exactly where it might have been. Proof above survives a
520
+ // partial read — a stamp is a fact about one database — but an inference drawn from missing files is not
521
+ // one, and `could-not-check` is the honest answer.
522
+ if (!unreadable && wholesaleRename(project, resources, candidates)) {
523
+ return { state: "drifted", project, misnamed };
524
+ }
525
+
526
+ return nothingToSay;
527
+ }
528
+
529
+ /** The old project name every mismatch leads with, deduplicated and joined — usually exactly one. */
530
+ function formerProjects(check: ProjectNameCheck): string {
531
+ return [...new Set(check.misnamed.map((entry) => entry.project))].sort((a, b) => a.localeCompare(b)).join(", ");
532
+ }
533
+
534
+ /**
535
+ * Why {@link isValidProjectName} refused this name, in the words of the rule itself.
536
+ *
537
+ * Asked of `assertValidProjectName` rather than restated, so the sentence stays true as that rule grows —
538
+ * a second copy here would keep describing the charset long after another constraint started doing the
539
+ * refusing, and doctor would name a rule the name does not break.
540
+ */
541
+ function projectNameProblem(name: string): string {
542
+ try {
543
+ assertValidProjectName(name);
544
+ return `"${name}" can't be a project name.`; // unreachable while the state is set by the same rule
545
+ } catch (error) {
546
+ if (!(error instanceof PithyError)) throw error;
547
+ return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
548
+ }
549
+ }
550
+
551
+ /** The one-line report for `renderDoctorText`, and the `action` a failing state should prompt. */
552
+ export function describeProjectName(check: ProjectNameCheck): string {
553
+ switch (check.state) {
554
+ case "ok":
555
+ return `${check.project} — every resource name matches`;
556
+ case "unconfigured":
557
+ return "not set (add `name` to pithy.config.ts — every resource name derives from it)";
558
+ case "invalid":
559
+ return `${projectNameProblem(check.project ?? "")} Nothing can be provisioned until it changes — pithy add, migrate, secrets, and token all refuse it.`;
560
+ case "could-not-check":
561
+ return "could not read every worker's wrangler.jsonc — nothing to compare the name against";
562
+ // Says only what the files say: every name this project declares belongs to another project. It does
563
+ // not claim those resources are ours — nothing has established that — so it names both readings of the
564
+ // contradiction and asks for one of them, and it never suggests deleting anything.
565
+ case "drifted": {
566
+ // Plural by construction — `wholesaleRename` needs two distinct names before it will say anything.
567
+ // Counted off `misnamed`, and phrased to claim exactly that: these are the names that lead with the
568
+ // other project, not the whole declared set. The wiring can also hold names Pithy's rule could never
569
+ // have produced — a hand-picked `legacy_store` is neither a candidate nor the configured name — so
570
+ // "all N resource names this project declares" was a number the check had not established.
571
+ const count = new Set(check.misnamed.map((entry) => entry.name)).size;
572
+ return `${count} resource names this project declares lead with "${formerProjects(check)}", and none leads with ${check.project} — pithy would provision a second set beside them; rename the project, or rename the resources`;
573
+ }
574
+ // Proven, and still no deletion advice: the stamp establishes one database's provenance, not the whole
575
+ // list's, and telling an adopter to delete a live database on anything less than certainty is the
576
+ // failure mode this guards. The action covers both readings of a foreign stamp — a rename to undo, or a
577
+ // binding pointed at another project's database, which `claimMigrationOwnership` refuses either way.
578
+ case "orphaned": {
579
+ const count = check.misnamed.filter((entry) => entry.owner === entry.project).length;
580
+ const subject = count === 1 ? "1 resource is" : `${count} resources are`;
581
+ return `${subject} stamped "${formerProjects(check)}" by pithy migrate, not ${check.project} — ${check.project} will never find ${count === 1 ? "it" : "them"} again; rename the project back, or move the data onto resources ${check.project} owns`;
582
+ }
583
+ }
584
+ }