@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,2059 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { platform as osPlatform, release as osRelease } from "node:os";
7
+ import { join } from "node:path";
8
+ import { PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import { defineCommand } from "citty";
10
+ import {
11
+ type BindingDeclines,
12
+ type BuildReconcilePlanOptions,
13
+ buildReconcilePlan,
14
+ type ReconcilePlan,
15
+ undeclinableReason,
16
+ } from "../capabilities/reconcile";
17
+ import { pithyOffline } from "../cloudflare/config";
18
+ import { type CloudflareAccess, checkCloudflareAccess, describeCloudflareAccess } from "../doctor/cloudflare";
19
+ import { checkDevPreferences, type DevPreferencesCheck, describeDevPreferences } from "../doctor/devPreferences";
20
+ import {
21
+ checkDevSecrets,
22
+ checkDevSecretsLocation,
23
+ type DevSecretsCheck,
24
+ type DevSecretsLocationCheck,
25
+ describeDevSecrets,
26
+ describeDevSecretsLocation,
27
+ devSecretsHealthy,
28
+ } from "../doctor/devSecrets";
29
+ import { checkDevVars, type DevVarsCheck, describeDevVars, devVarsHealthy } from "../doctor/devVars";
30
+ import { checkDevVarsLocal, type DevVarsLocalCheck, describeDevVarsLocal } from "../doctor/devVarsLocal";
31
+ import { checkEnvironments, describeEnvironmentDrift, type EnvironmentsCheck } from "../doctor/environments";
32
+ import { buildProjectHealth, type MigrationHealth, type ProjectHealth, type WorkerChecks } from "../doctor/health";
33
+ import { checkLocalDelivery, describeLocalDelivery, type LocalDeliveryCheck } from "../doctor/localDelivery";
34
+ import {
35
+ checkPortsRegistry,
36
+ describePortsRegistry,
37
+ type PortsRegistryCheck,
38
+ type PortsRegistryEntry,
39
+ } from "../doctor/portsRegistry";
40
+ import { checkProjectName, describeProjectName, type ProjectNameCheck } from "../doctor/projectName";
41
+ import { checkSecretBindings, describeSecretBindings, type SecretBindingsCheck } from "../doctor/secretBindings";
42
+ import {
43
+ describeSettingsAccount,
44
+ describeSettingsFinding,
45
+ type SettingsCheck,
46
+ type SettingsFindingEntry,
47
+ } from "../doctor/settings";
48
+ import { doctorSettingsCheck } from "../doctor/settingsSources";
49
+ import { checkWorkerNames, describeWorkerName, type WorkerNameCheck } from "../doctor/workerName";
50
+ import { describeUndeclared, undeclaredRemedy } from "../migrations/ledger";
51
+ import { type FetchLike, fetchLatestVersion } from "../notifier/check";
52
+ import { detectInstaller, type Installer, upgradeCommandFor } from "../notifier/installer";
53
+ import { readState, setNotifierFlag, stateDir, stateFilePath, writeState } from "../notifier/state";
54
+ import { classifyBump } from "../notifier/version";
55
+ import { readRcFile } from "../platform/rc";
56
+ import { detectShell, type ShellInfo } from "../platform/shell";
57
+ import { loadProject, type ProjectConfig, projectCloudflareAccount } from "../project/config";
58
+ import { checkOrigins, describeOriginDrift, type OriginsCheck } from "../project/domains";
59
+ import { checkExtensions, describeExtension, type ExtensionsCheck } from "../project/extensions";
60
+ import { type ResolvedWorker, resolveWorkers } from "../project/workerScope";
61
+ import { checkWorkflows, describeWorkflowDrift, type WorkflowsCheck } from "../project/workflows";
62
+ import { formatJsonLine, withErrorReporting } from "../terminal/output";
63
+
64
+ /**
65
+ * `pithy doctor` (docs/CLI.md §5.6): the user-initiated health check. It bypasses the 24-hour notifier
66
+ * cache for a fresh registry query, reports the full toolchain state (CLI version, shell/alias, config,
67
+ * project capability versions) plus a `Project health` block — `pithy upgrade`'s reconcile in read-only
68
+ * mode — and exits non-zero when a health check fails so CI can gate on drift. Toolchain state alone never
69
+ * fails the exit. Outside a Pithy project the `Project:` line says there is no config here and every other
70
+ * `Project*` line is omitted — including `Project name:`, because with no project there is no name question.
71
+ *
72
+ * The health block is **per Worker**: each Worker under `apps/` carries its own `pithy.config.ts` and
73
+ * `wrangler.jsonc`, so each drifts on its own. Any unhealthy Worker fails the exit. `--worker <name>`
74
+ * narrows the block to one — the same flag `migrate`, `seed`, `upgrade`, and `env` take (docs/CLI.md §1.1),
75
+ * so CI can gate on a single Worker's health.
76
+ */
77
+
78
+ /** The alias-block marker `pithy alias` writes — reused here to detect an installed `p.` shortcut. */
79
+ const ALIAS_MARKER = "# >>> pithy alias >>>";
80
+
81
+ /**
82
+ * Whether the `p.` alias is installed — **three states, because a boolean cannot say "I could not tell"**
83
+ * (#210).
84
+ *
85
+ * The rc file is the one thing in this report `doctor` reads out of the adopter's own shell config, and
86
+ * an unreadable one — wrong mode, a dangling symlink, an `EIO` — used to throw out of
87
+ * {@link buildDoctorReport} and take the entire report with it: Cloudflare reachability, the secrets
88
+ * paths, project health, dev secrets. The least important line in the report cost every other line.
89
+ *
90
+ * Catching it to `false` is not the fix, and is why #203 stopped at making the failure legible.
91
+ * `Alias: not installed` about a file nothing could read is a lie, and the adopter's next move on reading
92
+ * it is `pithy alias` — which fails on the same file. So the third state is said out loud, and it names
93
+ * the file, which is the only thing anybody can act on.
94
+ */
95
+ export type AliasState = "installed" | "not-installed" | "unknown";
96
+
97
+ /** The alias line's whole answer: which state, about which file, and why when nobody could tell. */
98
+ export interface AliasStatus {
99
+ state: AliasState;
100
+ /** The rc file the answer is about, or `null` when no shell was detected and none was resolved. */
101
+ rcPath: string | null;
102
+ /**
103
+ * Why the file would not read. Set exactly when {@link AliasStatus.state} is `unknown`.
104
+ *
105
+ * The refusal's own sentence — `readRcFile` owns those words (#203) — and never a byte of the file's
106
+ * contents: an rc file is where a developer keeps `export GITHUB_TOKEN=…`.
107
+ */
108
+ reason: string | null;
109
+ }
110
+
111
+ /**
112
+ * One actionable sentence from a thrown failure — `message` and `action`, never `detail`.
113
+ *
114
+ * `detail` is throw-site context and the HTTP codec strips it for a reason; these lines reach a terminal
115
+ * and `--json`.
116
+ */
117
+ function messageOf(error: unknown): string {
118
+ if (error instanceof PithyError) return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
119
+ return error instanceof Error ? error.message : String(error);
120
+ }
121
+
122
+ /**
123
+ * Read the rc file and answer all three ways it can go.
124
+ *
125
+ * No shell is `not-installed` rather than `unknown`: nothing was refused, there is simply no rc file this
126
+ * platform's detection would write to, and `pithy alias` prints manual instructions for exactly that case.
127
+ */
128
+ async function aliasStatus(shell: ShellInfo | null, readRc: (path: string) => Promise<string>): Promise<AliasStatus> {
129
+ if (!shell) return { state: "not-installed", rcPath: null, reason: null };
130
+ try {
131
+ const contents = await readRc(shell.rcPath);
132
+ return {
133
+ state: contents.includes(ALIAS_MARKER) ? "installed" : "not-installed",
134
+ rcPath: shell.rcPath,
135
+ reason: null,
136
+ };
137
+ } catch (error) {
138
+ return { state: "unknown", rcPath: shell.rcPath, reason: messageOf(error) };
139
+ }
140
+ }
141
+
142
+ const VERSION = (
143
+ JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }
144
+ ).version;
145
+
146
+ /**
147
+ * Whether a version could be compared against the registry at all.
148
+ *
149
+ * **`unknown` exists because a boolean cannot say "I did not find out".** The registry lookup fails for
150
+ * ordinary reasons — offline, an outage, a package not published yet — and collapsing that into
151
+ * `upToDate: true` made `doctor` report currency it had never established. A diagnostic that answers
152
+ * confidently when it could not check is worse than one that says nothing.
153
+ */
154
+ export type VersionState = "current" | "outdated" | "unknown";
155
+
156
+ /** The CLI-version block of the report. */
157
+ export interface CliStatus {
158
+ installed: string;
159
+ latest: string | null;
160
+ installer: Installer;
161
+ state: VersionState;
162
+ upgradeCommand: string;
163
+ }
164
+
165
+ /** One installed project capability's version state. */
166
+ export interface CapabilityStatus {
167
+ name: string;
168
+ installed: string;
169
+ latest: string | null;
170
+ state: VersionState;
171
+ }
172
+
173
+ /** The project-scoped portion of the report; `null` outside a Pithy project. */
174
+ export interface ProjectStatus {
175
+ capabilities: CapabilityStatus[];
176
+ health: ProjectHealth;
177
+ }
178
+
179
+ /** The complete doctor report — the structured source both the text and `--json` renderers project from. */
180
+ export interface DoctorReport {
181
+ cli: CliStatus;
182
+ shell: ShellInfo | null;
183
+ /** Whether the `p.` alias is installed, or whether its rc file could not be read at all (#210). */
184
+ alias: AliasStatus;
185
+ configDir: string;
186
+ stateFile: string;
187
+ notifierEnabled: boolean;
188
+ notifierDisabledBy: "env" | "state" | null;
189
+ project: ProjectStatus | null;
190
+ /** Set when a `pithy.config.ts` is present but could not be loaded (e.g. dependencies not installed). */
191
+ projectLoadError: string | null;
192
+ /** Whether the configured Cloudflare credentials actually reach the account. */
193
+ cloudflare: CloudflareAccess;
194
+ /**
195
+ * Whether the configured project name still matches the names this project's resources were provisioned
196
+ * under — `null` when the root config could not be read, so no name question arises. Set from the same
197
+ * `loadProject` outcome as {@link DoctorReport.project} and {@link DoctorReport.projectLoadError}, which
198
+ * is what keeps the two blocks from disputing whether there is a project here.
199
+ */
200
+ projectName: ProjectNameCheck | null;
201
+ /**
202
+ * Whether each Worker's three names still agree — its `apps/<dir>`, its deployed script name, and its
203
+ * `WORKER` var. `null` outside a readable project, on the same `loadProject` outcome as
204
+ * {@link DoctorReport.projectName}: with no project there are no Workers to name.
205
+ */
206
+ workerNames: WorkerNameCheck | null;
207
+ /**
208
+ * Whether every Worker's `env.<name>` stanzas are the environments the root config declares (#241), and
209
+ * whether a declaration changed after resources were provisioned under the old names. `null` outside a
210
+ * readable project, on the same `loadProject` outcome as the two above: with no declaration there is
211
+ * nothing to compare a stanza to.
212
+ */
213
+ environments: EnvironmentsCheck | null;
214
+ /**
215
+ * Whether every environment this project declares names every origin it answers on (#253). `null`
216
+ * outside a readable project, on the same `loadProject` outcome as the checks above: with no declared
217
+ * set there is nothing to ask about.
218
+ *
219
+ * The question `pithy deploy` refuses on, asked without being asked — so an environment with no origin,
220
+ * or one whose `workers.dev` subdomain nobody decided about, is findable before a deploy is attempted
221
+ * rather than at the end of one.
222
+ */
223
+ origins: OriginsCheck | null;
224
+ /**
225
+ * Whether each declared environment's `wrangler.jsonc` stanza binds exactly the Workflows and cron its
226
+ * Worker's app capability declares (#267). `null` outside a readable project, or one that names no
227
+ * project — a Workflow name leads with the project, so without one there is nothing to compare.
228
+ *
229
+ * The one fault in this report that is otherwise **completely silent**. A declared job that was never
230
+ * synced ships with no `workflows` entry and no `triggers.crons`: the cron never fires, and no request
231
+ * fails, no log line appears and no probe goes red to say so.
232
+ */
233
+ workflows: WorkflowsCheck | null;
234
+ /**
235
+ * What an adopter plugged into a capability — a Better Auth plugin composed through the auth
236
+ * capability's config is the first (#271). `null` outside a readable project, on the same
237
+ * `loadProject` outcome as the checks above: with no Workers there is nothing composed to read.
238
+ *
239
+ * It reports and **never fails the exit**, and it is the only block here that is not about a fault.
240
+ * An extension an adopter deliberately added is not drift. What it must not be is invisible: it has
241
+ * no `package.json` for `Project capabilities:` to name it from, and it adds routes to the Worker and
242
+ * tables to the database all the same.
243
+ */
244
+ extensions: ExtensionsCheck | null;
245
+ /**
246
+ * This project's dev-login preference file: where it goes, whether it is there, and whether it says
247
+ * anything a seed can use. `null` outside a readable project, on the same footing as the two above.
248
+ *
249
+ * It sits in the report because nothing else could say it. `dev.json` is machine-local and named by
250
+ * nothing in the checkout, and until this line doctor reported one config directory while `pithy seed`
251
+ * read a different one — so a developer whose dev login was not working looked where doctor pointed and
252
+ * found nothing, correctly.
253
+ */
254
+ devPreferences: DevPreferencesCheck | null;
255
+ /**
256
+ * Where this machine's dev-port registry is, and whether an older CLI left one in the checkout.
257
+ *
258
+ * It sits in the report for the reason `devPreferences` does, one step further out: the registry left
259
+ * the main repo root in #435, and being inside the checkout was the only thing that ever made it
260
+ * findable. It decides every port `pithy dev` binds and nothing in the project mentions it.
261
+ *
262
+ * `null` outside a project — the stray half of the check is a question about a checkout.
263
+ */
264
+ portsRegistry: PortsRegistryCheck | null;
265
+ /**
266
+ * Whether any declared `d1`-backed secret is still sitting in `.dev.vars`, and whether the secrets file
267
+ * is readable by anyone but its owner. `null` outside a project that composes `secrets` — with no
268
+ * registry there is no declared name, and so no misplaced one.
269
+ *
270
+ * It reports and never fails the exit. Every project that predates the dev secrets file has misplaced
271
+ * secrets by definition, and an upgrade that turns a green `pithy doctor` red in CI over a file that
272
+ * still works is a surprise rather than a diagnosis. Migration is told, not enforced.
273
+ */
274
+ devSecrets: Checked<DevSecretsCheck> | null;
275
+ /**
276
+ * Whether every declared environment binds the `cf-secrets-store` secrets its Worker reads (#238).
277
+ * `null` outside a project that composes `secrets` — with no registry there is nothing to bind.
278
+ *
279
+ * It reports and never fails the exit, on the same rule its neighbors follow: every project that
280
+ * composed `secrets` and has not yet run `pithy secrets provision` is in this state, and so is every
281
+ * project that predates the stanza existing at all. That is a step not yet taken rather than a
282
+ * contradiction. What it must not be is silent — until this line the only thing that reported a
283
+ * missing binding was the Worker's own 500 on its first request.
284
+ */
285
+ secretBindings: SecretBindingsCheck | null;
286
+ /**
287
+ * Whether each composed capability's **settings work**, as opposed to being merely present (#411).
288
+ * `null` when no capability any Worker composes declares a check — the ordinary case, and silent.
289
+ *
290
+ * Every other project check in this report asks a presence question, and all of them pass while
291
+ * `fromAddress` names a domain nobody onboarded, the link-signing key was never created, and
292
+ * `BASE_URL` points at a host nothing serves. Only the capability knows what its own values must be,
293
+ * so the capability declares the check and this runs it — keyed on the composed instance, never on a
294
+ * `pithy.manifest.json`, because two published capability packages ship none.
295
+ *
296
+ * **It fails the exit on a finding, from either tier.** A local finding is established from the
297
+ * project's own files; an account finding is established from an account that was actually reached.
298
+ * An account that could *not* be reached is reported as skipped and gates nothing, which is why the
299
+ * account tier's state is carried rather than inferred from an empty finding list.
300
+ */
301
+ settings: SettingsCheck | null;
302
+ /**
303
+ * Whether local email delivery is **live** — whether a magic link triggered from localhost leaves this
304
+ * machine or is written to disk (#410). `null` when nothing composed puts a message on the wire.
305
+ *
306
+ * Not a fault, and it never fails the exit: the simulator is a legitimate choice, and an offline
307
+ * machine has no other. It prints because silence would be read as "of course it sends", which is the
308
+ * assumption that had developers waiting on an inbox for mail no local process could ever have posted.
309
+ * The verdict is {@link deliveryPreflight}'s own, so this and `pithy dev` cannot disagree.
310
+ */
311
+ localDelivery: LocalDeliveryCheck | null;
312
+ /**
313
+ * What is in a `.dev.vars.local` that nothing else in the project knows about — a key that exists only
314
+ * in dev, and a key that shadows a registry secret. `null` when there is nothing to say, which is every
315
+ * project with no `.dev.vars.local` anywhere.
316
+ *
317
+ * It reports and never fails the exit, for the reason {@link ./devVarsLocal} gives: both states are
318
+ * legitimate, and neither may be invisible.
319
+ */
320
+ devVarsLocal: Checked<DevVarsLocalCheck> | null;
321
+ /**
322
+ * The two `.dev.vars` questions nothing else asks: whether each Worker's generated file actually
323
+ * carries anything, and whether the project root's hand-written one is still holding values nothing
324
+ * reads. `null` outside a readable project, on the same footing as the checks above.
325
+ *
326
+ * It reports and never fails the exit, for the same reason {@link DoctorReport.devSecrets} does not:
327
+ * every project that predates the generated file (#154) is in this state by definition, and an
328
+ * upgrade that turns a green `pithy doctor` red in CI is a surprise rather than a diagnosis. What it
329
+ * must not be is silent — until #178 the only thing that reported either was a 500 from a running
330
+ * Worker naming the bindings it did not have.
331
+ */
332
+ devVars: Checked<DevVarsCheck> | null;
333
+ /**
334
+ * Where this project's dev secrets file is, and whether it is there. `null` outside a project with a
335
+ * resolvable name, on the same footing as {@link DoctorReport.devPreferences}.
336
+ *
337
+ * **Separate from {@link DoctorReport.devSecrets}, and reported even when that one is `null`.** That
338
+ * check needs a registry to compare against, so a project that has not composed `secrets` yet gets no
339
+ * answer from it — and a location is still the question an adopter has, because the file is outside
340
+ * the checkout (#156) and nothing in the project names it. It never fails the exit and it is not a
341
+ * term in the terse predicate: a path is not a fault. It prints in **both** forms of the report for
342
+ * that same reason (#166) — a healthy project is the one most likely to be asking where the file is.
343
+ */
344
+ devSecretsFile: Checked<DevSecretsLocationCheck> | null;
345
+ os: { name: string; version: string };
346
+ /** The runtime actually executing, which under Bun is not what `process.versions.node` reports. */
347
+ runtime: RuntimeInfo;
348
+ node: string;
349
+ /**
350
+ * Whether this run refused ambient credentials and every network call — `PITHY_OFFLINE`, or `--offline`
351
+ * (#218).
352
+ *
353
+ * Carried in the report rather than read again at each renderer, because the two things it changes are
354
+ * both *wordings*: a version state of `unknown` means "the registry did not answer" on an ordinary run
355
+ * and "nobody asked it" on this one, and a diagnostic that reports the first when the second happened is
356
+ * describing a network fault that does not exist. `--json` carries it for the same reason one level up —
357
+ * a script reading `state: "unknown"` cannot otherwise tell a skipped check from a failed one.
358
+ */
359
+ offline: boolean;
360
+ }
361
+
362
+ /**
363
+ * A probe's own answer, or that the probe threw (#371).
364
+ *
365
+ * Eleven checks contribute to this report, and until #371 five of them could take the whole thing down
366
+ * while six were caught into `null` — which already meant *the question does not arise here*. Both halves
367
+ * were the same defect: a report is what an adopter reads to find out why something is wrong, and losing
368
+ * ten findings to an eleventh, or filing a failure under "not applicable", are two ways of not saying it.
369
+ *
370
+ * Every probe is guarded now, and every failure lands as a `state` **on the value**. Six of the eleven
371
+ * checks already carried a `could-not-check` member of their own and use it; `CloudflareAccess` and
372
+ * `DevPreferencesCheck` gained one; and the four whose payload is a bag of findings with no discriminant
373
+ * wear this wrapper, so the finding fields are unreachable without narrowing. One representation — a
374
+ * `state` on the value — in four vocabularies, which is how `#210` and `#350` differ too.
375
+ *
376
+ * **`null` still means the question does not arise**, and that is what keeps the two apart: a project that
377
+ * composes no `secrets` has no dev-secrets question, and a project whose registry would not load has one
378
+ * nobody answered.
379
+ */
380
+ export type Checked<T> = ({ state: "checked" } & T) | { state: "could-not-check" };
381
+
382
+ /**
383
+ * Run a probe, and turn a throw into `could-not-check` rather than into a lost report (#371).
384
+ *
385
+ * The `catch` takes no binding, on `#350`'s rule: these probes read an adopter's config files, their
386
+ * `.dev.vars`, and their Cloudflare credentials, so what they throw names paths, account ids and — in the
387
+ * worst case — a value. Nothing derived from it reaches a terminal or `--json`. What survives is which
388
+ * check could not run, which is the one thing anybody can act on and which the line already names.
389
+ */
390
+ async function probed<T>(probe: () => Promise<T>, unavailable: T): Promise<T> {
391
+ try {
392
+ return await probe();
393
+ } catch {
394
+ return unavailable;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The same guard for a check whose payload has no discriminant of its own — see {@link Checked}.
400
+ *
401
+ * `try`/`catch` rather than `.catch()` in both, and that is not style. Every one of these probes is an
402
+ * injectable seam, and a seam that throws *before* returning a promise is not a rejected promise —
403
+ * `.catch()` never sees it, and the report dies exactly as it did before the guard was written.
404
+ */
405
+ async function checkedProbe<T>(probe: () => Promise<T | null>): Promise<Checked<T> | null> {
406
+ try {
407
+ const answer = await probe();
408
+ // A probe's own `null` is preserved, never wrapped. It is the third fact this report already had a
409
+ // word for — the question does not arise here — and folding it into `checked` would trade one
410
+ // conflation for another.
411
+ return answer === null ? null : { state: "checked", ...answer };
412
+ } catch {
413
+ return { state: "could-not-check" };
414
+ }
415
+ }
416
+
417
+ /**
418
+ * The `devSecrets` payload: every finding with its own sentence, or the state that says nobody looked.
419
+ *
420
+ * Lifted out of the JSON renderer because it is the one probe whose payload is long enough that a
421
+ * conditional expression around it stopped being readable — and because #371 gave it a second shape a
422
+ * consumer has to be able to tell apart from `null`. `null` is "this project composes no secrets";
423
+ * `{ state: "could-not-check" }` is "it does and nobody could read the registry".
424
+ */
425
+ function jsonDevSecrets(value: Checked<DevSecretsCheck> | null): Record<string, unknown> | null {
426
+ if (value === null) return null;
427
+ const check = reported(value);
428
+ if (check === null) return { state: "could-not-check" };
429
+ return {
430
+ state: "checked",
431
+ path: check.path,
432
+ misplaced: check.misplaced,
433
+ missing: check.missing,
434
+ // The two the human block has printed since #323 and this payload did not carry (#325). A JSON
435
+ // consumer could not see the fault class that wave added — and `malformed` is the one that flips
436
+ // the exit, so a script read a value the next seed refuses as a healthy project.
437
+ bootstrapMissing: check.bootstrapMissing,
438
+ malformed: check.malformed,
439
+ undeclared: check.undeclared,
440
+ mode: check.mode === null ? null : check.mode.toString(8),
441
+ // The loader's sentence since #323, and a `string | null` ever since. A script gating on
442
+ // `unreadable === true` stopped firing the moment it stopped being a boolean, and stopped silently,
443
+ // because a non-empty string is not `false` — it is merely not `true`. The sentence stays, because
444
+ // it names the secret and the shape; `healthy` is what a gate reads.
445
+ unreadable: check.unreadable,
446
+ // The one field a script can gate on without enumerating fault names. Through the same function the
447
+ // exit code is computed from, so the payload and the exit cannot come to two answers — and so the
448
+ // next fault class added here needs no consumer to be updated (#325).
449
+ healthy: devSecretsHealthy(check),
450
+ // The Workers this project has that nobody could ask what they declare (#208). Carried here because
451
+ // a `devSecrets` object with no targets and a `null` one used to be the same answer, and an agent
452
+ // reading either had no way to tell "no secrets" from "nothing loaded".
453
+ unresolvable: check.unresolvable,
454
+ detail: describeDevSecrets(check),
455
+ };
456
+ }
457
+
458
+ /**
459
+ * The check when the probe answered, and `null` when it either did not arise or could not run.
460
+ *
461
+ * A convenience for the render sites that treat "no answer" alike — the ones that must say *which*
462
+ * kind of no-answer it is read `state` directly, and there are lines below that do.
463
+ */
464
+ function reported<T>(value: Checked<T> | null): T | null {
465
+ return value?.state === "checked" ? value : null;
466
+ }
467
+
468
+ /**
469
+ * The interpreter running the CLI.
470
+ *
471
+ * Reporting `process.versions.node` alone is actively wrong under Bun: Bun sets it to the Node version it
472
+ * emulates, so `doctor` would name a runtime that is not executing — the one thing a diagnostic must not do.
473
+ * Both are kept, because the emulated level is what the `engines.node >= 22` floor is judged against.
474
+ */
475
+ export interface RuntimeInfo {
476
+ /** `Bun` or `Node`. */
477
+ name: string;
478
+ /** The runtime's own version. */
479
+ version: string;
480
+ /** The Node version being emulated, when the runtime is not Node itself. */
481
+ nodeCompat: string | null;
482
+ }
483
+
484
+ /** Detect the executing runtime. `process.versions.bun` is Bun-only and absent from Node's typings. */
485
+ export function detectRuntime(versions: NodeJS.ProcessVersions = process.versions): RuntimeInfo {
486
+ const bun = (versions as { bun?: string }).bun;
487
+ if (bun) return { name: "Bun", version: bun, nodeCompat: versions.node };
488
+ return { name: "Node", version: versions.node, nodeCompat: null };
489
+ }
490
+
491
+ /** Classify an installed version against what the registry returned — `unknown` when it returned nothing. */
492
+ export function versionState(installed: string, latest: string | null): VersionState {
493
+ if (latest === null) return "unknown";
494
+ return classifyBump(installed, latest) === "none" ? "current" : "outdated";
495
+ }
496
+
497
+ /** Map a Node platform id to a human OS name. */
498
+ function osName(platform: NodeJS.Platform): string {
499
+ if (platform === "darwin") return "macOS";
500
+ if (platform === "win32") return "Windows";
501
+ if (platform === "linux") return "Linux";
502
+ return platform;
503
+ }
504
+
505
+ /** Options for {@link buildDoctorReport} — every environment and network dependency is injectable for tests. */
506
+ export interface DoctorReportOptions {
507
+ projectDir: string;
508
+ /** Narrow the health block to one Worker, by name or `apps/<dir>` basename. Every Worker when omitted. */
509
+ worker?: string;
510
+ installedVersion?: string;
511
+ fetch?: FetchLike;
512
+ now?: () => number;
513
+ stateFile?: string;
514
+ argv1?: string;
515
+ env?: NodeJS.ProcessEnv;
516
+ homedir?: string;
517
+ os?: { name: string; version: string };
518
+ /** Runtime seam; defaults to {@link detectRuntime}. */
519
+ runtime?: RuntimeInfo;
520
+ node?: string;
521
+ /**
522
+ * Refuse ambient credentials and every network call. Defaults to {@link pithyOffline} over `env`.
523
+ *
524
+ * The flag's route in. Explicit `false` is a caller saying so and the variable does not override it —
525
+ * the same reading `CloudflareConfigOptions.offline` takes, because the two have to agree or a run is
526
+ * offline in one half of itself.
527
+ */
528
+ offline?: boolean;
529
+ /** Shell detection seam; defaults to {@link detectShell}. */
530
+ detectShell?: () => Promise<ShellInfo | null>;
531
+ /** rc-file reader seam; defaults to {@link readRcFile}. */
532
+ readRc?: (path: string) => Promise<string>;
533
+ /** Installed-capability enumerator seam; defaults to scanning `node_modules/@pithy-sh/*`. */
534
+ installedCapabilities?: (projectDir: string) => Promise<{ name: string; version: string }[]>;
535
+ /** Project-config loader seam; defaults to {@link loadProject} (a `NotFoundError` marks "outside a project"). */
536
+ loadProject?: (projectDir: string) => Promise<ProjectConfig>;
537
+ /** Worker-set resolver seam; defaults to {@link resolveWorkers}. The health block reports one entry per Worker. */
538
+ resolveWorkers?: (options: { projectDir: string; worker?: string }) => Promise<ResolvedWorker[]>;
539
+ /** Health plan-builder seam, forwarded to {@link buildProjectHealth}. */
540
+ buildPlan?: (options: BuildReconcilePlanOptions) => Promise<ReconcilePlan>;
541
+ /** Migration-ledger seam for the health plan. */
542
+ readLedger?: BuildReconcilePlanOptions["readLedger"];
543
+ /**
544
+ * Cloudflare-credential probe seam; defaults to {@link checkCloudflareAccess}. Injected so unit tests
545
+ * never call out. It takes no project directory: the credentials are account-scoped (#182), so the
546
+ * answer is the same in every checkout on this machine.
547
+ */
548
+ checkCloudflare?: () => Promise<CloudflareAccess>;
549
+ /** Project-name probe seam; defaults to {@link checkProjectName}. Injected so unit tests never call out. */
550
+ checkProjectName?: (projectDir: string) => Promise<ProjectNameCheck | null>;
551
+ /** Worker-name agreement seam; defaults to {@link checkWorkerNames}. Reads files only — no account call. */
552
+ checkWorkerNames?: (projectDir: string) => Promise<WorkerNameCheck>;
553
+ /** Environment-declaration seam; defaults to {@link checkEnvironments}. Reads files only — no account call. */
554
+ checkEnvironments?: (projectDir: string) => Promise<EnvironmentsCheck>;
555
+ /** Origin-declaration seam; defaults to {@link checkOrigins}. Reads files only — no account call. */
556
+ checkOrigins?: (projectDir: string) => Promise<OriginsCheck>;
557
+ /** App-Workflow binding seam; defaults to {@link checkWorkflows}. Reads files only — no account call. */
558
+ checkWorkflows?: (projectDir: string) => Promise<WorkflowsCheck>;
559
+ /**
560
+ * Dev-login preference seam; defaults to {@link checkDevPreferences} resolved against the same `homedir`
561
+ * and `env` the config directory is, so the line can never name a path this report did not resolve.
562
+ */
563
+ checkDevPreferences?: (projectDir: string) => Promise<DevPreferencesCheck | null>;
564
+ /**
565
+ * Port-registry seam; defaults to {@link checkPortsRegistry} resolved against the same `homedir` and
566
+ * `env` the config directory is, so the line can never name a path this report did not resolve.
567
+ */
568
+ checkPortsRegistry?: (projectDir: string) => Promise<PortsRegistryCheck>;
569
+ /** Seam: whether this project's secrets are in the file they belong in, without loading real configs. */
570
+ checkDevSecrets?: (projectDir: string) => Promise<DevSecretsCheck | null>;
571
+ /** Seam: whether every declared environment binds the Secrets Store entries its Worker reads. */
572
+ checkSecretBindings?: (projectDir: string) => Promise<SecretBindingsCheck | null>;
573
+ /** Seam: what this project's `.dev.vars.local` files carry that nothing else declares. */
574
+ checkDevVarsLocal?: (projectDir: string) => Promise<DevVarsLocalCheck | null>;
575
+ /**
576
+ * Seam: whether each Worker's generated `.dev.vars` carries anything, and what the root one is
577
+ * holding that nothing reads. Resolved against the same `homedir` and `env` every other per-project
578
+ * path in this report is, so the file it names can never be one this report did not resolve.
579
+ */
580
+ checkDevVars?: (projectDir: string) => Promise<DevVarsCheck | null>;
581
+ /**
582
+ * Settings-check seam; defaults to {@link doctorSettingsCheck} bound to this run's account and offline
583
+ * mode. It takes the resolved Workers rather than a directory, because the checks hang off the composed
584
+ * `Capability` instances this report already holds — nothing here re-enumerates `apps/`, so
585
+ * `--worker <name>` narrows this block exactly as it narrows the health block.
586
+ */
587
+ checkSettings?: (options: {
588
+ projectDir: string;
589
+ workers: readonly ResolvedWorker[];
590
+ }) => Promise<SettingsCheck | null>;
591
+ /**
592
+ * Local-delivery seam; defaults to {@link checkLocalDelivery} over the same resolved Workers. Like the
593
+ * settings probe it takes the composition rather than a directory, so nothing here re-enumerates
594
+ * `apps/` and `--worker <name>` narrows it the same way.
595
+ */
596
+ checkLocalDelivery?: (options: {
597
+ projectDir: string;
598
+ workers: readonly ResolvedWorker[];
599
+ }) => Promise<LocalDeliveryCheck | null>;
600
+ /**
601
+ * Dev-secrets location seam; defaults to {@link checkDevSecretsLocation} resolved against the same
602
+ * `homedir` and `env` the config directory is, so the line can never name a path this report did not
603
+ * resolve — the defect #131 fixed for `dev.json`, in the file beside it.
604
+ */
605
+ checkDevSecretsFile?: (projectDir: string) => Promise<DevSecretsLocationCheck | null>;
606
+ }
607
+
608
+ /** Enumerate installed `@pithy-sh/*` packages (excluding the CLI itself) with their versions, name-sorted. */
609
+ export async function installedCapabilityVersions(projectDir: string): Promise<{ name: string; version: string }[]> {
610
+ const scopeDir = join(projectDir, "node_modules", "@pithy-sh");
611
+ let entries: string[];
612
+ try {
613
+ entries = await readdir(scopeDir);
614
+ } catch {
615
+ return [];
616
+ }
617
+ const found: { name: string; version: string }[] = [];
618
+ for (const entry of entries) {
619
+ if (entry === "cli") continue; // the CLI binary is the top block, not a project capability
620
+ try {
621
+ const raw = await readFile(join(scopeDir, entry, "package.json"), "utf8");
622
+ const version = (JSON.parse(raw) as { version?: string }).version;
623
+ if (typeof version === "string") found.push({ name: `@pithy-sh/${entry}`, version });
624
+ } catch {
625
+ // No package.json / unreadable — skip it.
626
+ }
627
+ }
628
+ return found.sort((a, b) => a.name.localeCompare(b.name));
629
+ }
630
+
631
+ /** Build the full structured report — always querying the registry fresh (doctor bypasses the notifier cache). */
632
+ /**
633
+ * The settings verdict when the probe itself never ran — the root config states no name, a Worker's
634
+ * `pithy.config.ts` would not import, the seam threw.
635
+ *
636
+ * Not `null`, which is the documented shape for "no composed capability declares a check": an agent
637
+ * reading that after a config that would not load concludes this project has no settings questions.
638
+ * And the account tier is `not-declared` rather than `unreachable`, because nothing here reached for an
639
+ * account — a local failure rendered as one Cloudflare caused sends the reader to the wrong machine.
640
+ */
641
+ function settingsNotRun(): SettingsCheck {
642
+ return {
643
+ state: "could-not-check",
644
+ account: { state: "skipped", reason: "not-declared" },
645
+ checked: [],
646
+ findings: [],
647
+ unchecked: [],
648
+ };
649
+ }
650
+
651
+ export async function buildDoctorReport(options: DoctorReportOptions): Promise<DoctorReport> {
652
+ const installed = options.installedVersion ?? VERSION;
653
+ const env = options.env ?? process.env;
654
+ const argv1 = options.argv1 ?? process.argv[1] ?? "";
655
+ const installer = detectInstaller(argv1);
656
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as FetchLike);
657
+ const now = options.now ?? Date.now;
658
+ const file = options.stateFile ?? stateFilePath();
659
+ const detect = options.detectShell ?? (() => detectShell());
660
+ const readRc = options.readRc ?? readRcFile;
661
+ const listCapabilities = options.installedCapabilities ?? installedCapabilityVersions;
662
+ // The account this project belongs to, read before the probe so `doctor` reports on the credentials
663
+ // this project's own commands resolve. Outside a project there is none, and the unnamed file is the
664
+ // right answer — a config that will not load is what the `Project:` line is for, not this one.
665
+ const account = await projectCloudflareAccount(options.projectDir).catch(() => null);
666
+ // Resolved once, here, and handed down. Every consumer of it below is asking the same question, and a
667
+ // report that computed it twice could answer differently in its own two halves.
668
+ const offline = options.offline ?? pithyOffline(env);
669
+ const probeCloudflare =
670
+ options.checkCloudflare ??
671
+ (() => checkCloudflareAccess({ ...(options.homedir ? { homedir: options.homedir } : {}), env, account, offline }));
672
+ const probePortsRegistry = (): Promise<PortsRegistryCheck> =>
673
+ (
674
+ options.checkPortsRegistry ??
675
+ ((dir: string) => checkPortsRegistry(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }))
676
+ )(options.projectDir);
677
+ const probeProjectName = options.checkProjectName ?? checkProjectName;
678
+ const probeWorkerNames = options.checkWorkerNames ?? checkWorkerNames;
679
+ const probeEnvironments = options.checkEnvironments ?? checkEnvironments;
680
+ const probeOrigins = options.checkOrigins ?? checkOrigins;
681
+ const probeWorkflows = options.checkWorkflows ?? checkWorkflows;
682
+ const probeDevPreferences =
683
+ options.checkDevPreferences ??
684
+ ((dir: string) => checkDevPreferences(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }));
685
+ const probeDevSecrets = options.checkDevSecrets ?? ((dir: string) => checkDevSecrets({ projectDir: dir }));
686
+ const probeSecretBindings =
687
+ options.checkSecretBindings ?? ((dir: string) => checkSecretBindings({ projectDir: dir }));
688
+ const probeDevVarsLocal = options.checkDevVarsLocal ?? ((dir: string) => checkDevVarsLocal({ projectDir: dir }));
689
+ const probeDevVars =
690
+ options.checkDevVars ??
691
+ ((dir: string) =>
692
+ checkDevVars({
693
+ projectDir: dir,
694
+ paths: { ...(options.homedir ? { homedir: options.homedir } : {}), env },
695
+ }));
696
+ const probeSettings =
697
+ options.checkSettings ??
698
+ ((scope: { projectDir: string; workers: readonly ResolvedWorker[] }) =>
699
+ doctorSettingsCheck({
700
+ ...scope,
701
+ // The same account and the same offline decision the `Cloudflare:` block above reports on,
702
+ // resolved once at the top of this function. A settings check that asked a second account would
703
+ // be a second report inside this one.
704
+ account,
705
+ offline,
706
+ ...(options.homedir ? { homedir: options.homedir } : {}),
707
+ env,
708
+ }));
709
+ const probeLocalDelivery =
710
+ options.checkLocalDelivery ??
711
+ ((scope: { projectDir: string; workers: readonly ResolvedWorker[] }) => checkLocalDelivery({ ...scope, env }));
712
+ const probeDevSecretsFile =
713
+ options.checkDevSecretsFile ??
714
+ ((dir: string) => checkDevSecretsLocation(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }));
715
+
716
+ // Fresh CLI-version check, then persist it into the notifier state (installer detected once when unknown).
717
+ //
718
+ // **Not asked at all when offline.** This one is unauthenticated and reaches npm rather than Cloudflare,
719
+ // so it is not the credential leak #218 is about — but a mode that says no network and then blocks for a
720
+ // DNS timeout on a plane has told the adopter something untrue, and the state it would land in
721
+ // (`unknown`) is the same one a registry outage produces. Skipping it keeps `unknown` meaning one thing
722
+ // per run, which the two version lines then say in the run's own words.
723
+ const cliLatest = offline ? null : await fetchLatestVersion("cli", { fetch: doFetch });
724
+ const state = await readState(file);
725
+ // Discarded on failure, like every read in this command and for the same reason (#210). The notifier
726
+ // cache is bookkeeping for the *next* run — a config directory that will not take a write is exactly
727
+ // the machine somebody runs `doctor` on, and losing the whole report over a file nothing in it reports
728
+ // is the shape this command exists to not have.
729
+ await writeState(file, {
730
+ ...state,
731
+ lastCheck: now(),
732
+ latestVersion: cliLatest?.version ?? state.latestVersion,
733
+ securityFlagged: cliLatest?.securityFlagged ?? state.securityFlagged,
734
+ installer: state.installer === "unknown" ? installer : state.installer,
735
+ }).catch(() => undefined);
736
+
737
+ const cli: CliStatus = {
738
+ installed,
739
+ latest: cliLatest?.version ?? null,
740
+ installer,
741
+ state: versionState(installed, cliLatest?.version ?? null),
742
+ upgradeCommand: upgradeCommandFor(installer),
743
+ };
744
+
745
+ // Shell / alias. The rc read is the only one in this report that reaches into the adopter's own shell
746
+ // config, and it is the least important line here — so it becomes a state rather than an exception.
747
+ const shell = await detect();
748
+ const alias = await aliasStatus(shell, readRc);
749
+
750
+ // Notifier state for display.
751
+ const disabledByEnv = Boolean(env.PITHY_NO_UPDATE_NOTIFIER);
752
+ const disabledByState = state.notifier === false;
753
+ const notifierEnabled = !disabledByEnv && !disabledByState;
754
+ const notifierDisabledBy = disabledByEnv ? "env" : disabledByState ? "state" : null;
755
+
756
+ // Project block — omitted outside a Pithy project.
757
+ let project: ProjectStatus | null = null;
758
+ let projectLoadError: string | null = null;
759
+ // Filled from the resolved Workers below, where the composed capabilities are already in hand — this
760
+ // check needs no file of its own and reaches nothing.
761
+ let extensions: ExtensionsCheck | null = null;
762
+ // Held out of the `try` so the guarded probe below still has the composition to run, even when a later
763
+ // step of the project block threw. The settings checks are adopter code reached through a live
764
+ // `import()`, so they are guarded like every other probe rather than run inside the block.
765
+ let resolvedWorkers: readonly ResolvedWorker[] = [];
766
+ // Whether that list is the project's composition or merely the value it was initialized to. An empty
767
+ // list is a legitimate answer and an unresolved one is not, and the settings probe is the one reader
768
+ // that cannot tell them apart on its own: over `[]` it answers `null`, which the JSON contract defines
769
+ // as "no composed capability declares a check". A project whose `pithy.config.ts` would not import
770
+ // would read as one with no settings questions.
771
+ let workersResolved = false;
772
+ const load = options.loadProject ?? loadProject;
773
+ const resolve = options.resolveWorkers ?? resolveWorkers;
774
+ // Set the moment the root config loads, so a `core/not_found` raised *later* (no workers under apps/)
775
+ // is reported as a broken project rather than mistaken for "outside a project".
776
+ let inProject = false;
777
+ try {
778
+ await load(options.projectDir);
779
+ inProject = true;
780
+ const installedCaps = await listCapabilities(options.projectDir);
781
+ const capabilities: CapabilityStatus[] = [];
782
+ for (const cap of installedCaps) {
783
+ const unscoped = cap.name.replace("@pithy-sh/", "");
784
+ const latest = offline ? null : await fetchLatestVersion(unscoped, { fetch: doFetch });
785
+ capabilities.push({
786
+ name: cap.name,
787
+ installed: cap.version,
788
+ latest: latest?.version ?? null,
789
+ state: versionState(cap.version, latest?.version ?? null),
790
+ });
791
+ }
792
+ const workers = await resolve({
793
+ projectDir: options.projectDir,
794
+ ...(options.worker !== undefined ? { worker: options.worker } : {}),
795
+ });
796
+ resolvedWorkers = workers;
797
+ workersResolved = true;
798
+ extensions = checkExtensions(workers.map((worker) => ({ name: worker.name, capabilities: worker.capabilities })));
799
+ const health = await buildProjectHealth({
800
+ projectDir: options.projectDir,
801
+ env: "dev",
802
+ // The same account the `Cloudflare:` block above reports on, resolved once at the top of this
803
+ // function. A doctor that counted pending migrations against one account and named another in the
804
+ // line beside it would be two reports in one (#234).
805
+ account,
806
+ workers: workers.map((worker) => ({
807
+ name: worker.name,
808
+ dir: worker.dir,
809
+ capabilities: worker.capabilities,
810
+ // Optional-chained because `resolveWorkers` is a test seam: a double that supplies no config
811
+ // is a Worker that declines nothing, not a crash in the report it was called to produce.
812
+ config: worker.config,
813
+ })),
814
+ buildPlan: options.buildPlan,
815
+ readLedger: options.readLedger,
816
+ });
817
+ project = { capabilities, health };
818
+ } catch (error) {
819
+ if (error instanceof PithyError && !inProject && error.payload.code === "core/not_found") {
820
+ project = null; // outside a Pithy project — omit the Project* lines entirely
821
+ } else if (error instanceof PithyError) {
822
+ // A pithy.config.ts is present but the project could not be read — its config would not load (most often:
823
+ // dependencies not installed), or it holds no Worker to check. Degrade to a toolchain report with an
824
+ // actionable project line rather than aborting: a diagnostic command must still work in exactly the
825
+ // broken-environment case it exists to diagnose. Drives a non-zero exit below.
826
+ projectLoadError = `${error.payload.message} ${error.payload.action ?? ""}`.trim();
827
+ } else {
828
+ throw error; // a genuine CLI bug — keep its stack
829
+ }
830
+ }
831
+
832
+ // Credentials are checked whether or not a project loaded: `.dev.vars` is read from the directory, and
833
+ // "are my credentials right" is a question worth answering before `pithy init` as much as after.
834
+ // Every probe below is guarded, and each failure lands on that check's own value (#371). None of them
835
+ // is load-bearing: they are eleven independent questions about one project, and there is no order in
836
+ // which one's answer is a precondition for another's.
837
+ const cloudflare = await probed<CloudflareAccess>(probeCloudflare, {
838
+ state: "probe_failed",
839
+ missing: [],
840
+ tokenStatus: null,
841
+ credentialSplit: null,
842
+ });
843
+ // The name is different, and it is asked only of a project whose root config actually loaded. It is not a
844
+ // question about the directory, it is a question about a config: "is the `name` in this file still the one
845
+ // every provisioned resource was named under". With no readable config there is no name and no question,
846
+ // and the `Project:` block below already reports which of the two happened. Gated on the same `inProject`
847
+ // that block is written from, so neither can contradict the other about whether a project is here.
848
+ const projectName = inProject
849
+ ? await probed<ProjectNameCheck | null>(() => probeProjectName(options.projectDir), {
850
+ state: "could-not-check",
851
+ project: null,
852
+ misnamed: [],
853
+ })
854
+ : null;
855
+ // The same question one level down, and gated the same way. `checkProjectName` asks whether this
856
+ // project's name still names its resources; this asks whether each Worker's own three names still name
857
+ // one Worker. Files only, so it costs nothing and answers offline.
858
+ const workerNames = inProject
859
+ ? await probed<WorkerNameCheck>(() => probeWorkerNames(options.projectDir), {
860
+ state: "could-not-check",
861
+ mismatches: [],
862
+ })
863
+ : null;
864
+ // And once more, one level out: the declaration is project-wide, so with no readable config there is
865
+ // nothing to compare each Worker's stanzas to. Files only, so it answers offline like the two above.
866
+ const environments = inProject
867
+ ? await probed<EnvironmentsCheck>(() => probeEnvironments(options.projectDir), {
868
+ state: "could-not-check",
869
+ declared: [],
870
+ drift: [],
871
+ })
872
+ : null;
873
+ // One level further out again, and gated the same way: an environment is declared, and now what is
874
+ // true *about* it — where it answers — must be too. Files only, so it answers offline like the rest.
875
+ const origins = inProject
876
+ ? await probed<OriginsCheck>(() => probeOrigins(options.projectDir), { state: "could-not-check", drift: [] })
877
+ : null;
878
+ // The same shape once more, on the other thing a `pithy.config.ts` declares and a `wrangler.jsonc` has
879
+ // to agree with: the app capability's Workflows and cron. Files only, so it answers offline like the rest.
880
+ const workflows = inProject
881
+ ? await probed<WorkflowsCheck>(() => probeWorkflows(options.projectDir), { state: "could-not-check", drift: [] })
882
+ : null;
883
+ // Gated the same way once more: `dev.json` is keyed by the project's own name, so with no readable config
884
+ // there is no path to resolve and no file to look for. Files only, no account, no seed run.
885
+ const devPreferences = inProject
886
+ ? await probed<DevPreferencesCheck | null>(() => probeDevPreferences(options.projectDir), {
887
+ // The config directory this report already resolved, never a `dev.json` path this run did not
888
+ // compute — resolving that path is what failed (#131's rule, applied to #371's state). Nothing
889
+ // prints it: the `Dev login:` line drops the path entirely in this state.
890
+ state: "could-not-check",
891
+ path: stateDir({ homedir: options.homedir, env }),
892
+ user: null,
893
+ })
894
+ : null;
895
+ // Gated the same way, and files only: the two `.dev.` files and each Worker's registry. No account, no
896
+ // database, no seed run — so it answers offline, in the project that is not working.
897
+ const devSecrets = inProject ? await checkedProbe(() => probeDevSecrets(options.projectDir)) : null;
898
+ // Gated the same way, and no account: the registry, two `stat` calls, and one `git rev-parse` — the
899
+ // spawn this check was written to avoid, and the only thing that says which of the machine's blocks are
900
+ // this checkout's, since a worktree's directory is not the registry key. Guarded inside the check. It
901
+ // reports a location, so its could-not-check state is simply `null` — see `probePortsRegistry`.
902
+ const portsRegistry = inProject ? await probed<PortsRegistryCheck | null>(() => probePortsRegistry(), null) : null;
903
+ // The same registry, asked about the deployed environments rather than the local file. Files only once
904
+ // more: whether a store *entry* exists is provisioning's question, and this one is whether the stanza
905
+ // that would bind it is there at all (#238).
906
+ const secretBindings = inProject
907
+ ? await probed<SecretBindingsCheck | null>(() => probeSecretBindings(options.projectDir), {
908
+ state: "could-not-check",
909
+ missing: [],
910
+ })
911
+ : null;
912
+ // Gated the same way, and asked separately: this one needs no registry, so it answers for a project
913
+ // that has never composed `secrets` — which is the project most likely to be asking where the file is.
914
+ const devSecretsFile = inProject ? await checkedProbe(() => probeDevSecretsFile(options.projectDir)) : null;
915
+ const devVarsLocal = inProject ? await checkedProbe(() => probeDevVarsLocal(options.projectDir)) : null;
916
+ // Gated the same way, and files only once more: each Worker's generated `.dev.vars` and the root's.
917
+ // No account, no store, no seed run — which matters here more than anywhere, because the state it
918
+ // reports is a project whose Workers cannot start.
919
+ const devVars = inProject ? await checkedProbe(() => probeDevVars(options.projectDir)) : null;
920
+ // The one project check that runs the capabilities' own code. Gated on `inProject` like the rest, and
921
+ // guarded like the rest — a capability's check that throws must cost this report that capability's
922
+ // verdict and nothing else. `could-not-check` here is the whole-probe failure; a single capability's is
923
+ // carried inside the value, as `unchecked`.
924
+ const settings = !inProject
925
+ ? null
926
+ : workersResolved
927
+ ? await probed<SettingsCheck | null>(
928
+ () => probeSettings({ projectDir: options.projectDir, workers: resolvedWorkers }),
929
+ settingsNotRun(),
930
+ )
931
+ : settingsNotRun();
932
+
933
+ // The same composition the settings probe runs over, asked the one question `Settings:` does not: not
934
+ // whether the values work, but whether anything this machine sends would actually leave it. Guarded
935
+ // like every other probe, and `null` on a failure — an unanswered delivery question is exactly as
936
+ // silent as a project that composes nothing which sends.
937
+ const localDelivery =
938
+ inProject && workersResolved
939
+ ? await probed<LocalDeliveryCheck | null>(
940
+ () => probeLocalDelivery({ projectDir: options.projectDir, workers: resolvedWorkers }),
941
+ null,
942
+ )
943
+ : null;
944
+
945
+ return {
946
+ cli,
947
+ shell,
948
+ alias,
949
+ configDir: stateDir({ homedir: options.homedir, env }),
950
+ stateFile: file,
951
+ notifierEnabled,
952
+ notifierDisabledBy,
953
+ project,
954
+ projectLoadError,
955
+ cloudflare,
956
+ projectName,
957
+ workerNames,
958
+ environments,
959
+ origins,
960
+ workflows,
961
+ extensions,
962
+ devPreferences,
963
+ portsRegistry,
964
+ devSecrets,
965
+ secretBindings,
966
+ settings,
967
+ localDelivery,
968
+ devSecretsFile,
969
+ devVarsLocal,
970
+ devVars,
971
+ os: options.os ?? { name: osName(osPlatform()), version: osRelease() },
972
+ runtime: options.runtime ?? detectRuntime(),
973
+ node: options.node ?? process.versions.node,
974
+ offline,
975
+ };
976
+ }
977
+
978
+ /** Non-zero exit when a health check fails or the project could not load (the CI gate). Toolchain state never fails it. */
979
+ export function doctorExitCode(report: DoctorReport): number {
980
+ if (report.projectLoadError) return 1;
981
+ // Configured-but-broken credentials are drift worth gating CI on. Absent ones are not: a project that has
982
+ // not been provisioned yet is a legitimate state, and failing it would make `doctor` useless before setup.
983
+ // `not_checked` is the same standard once more and the clearest case of it — the check did not run, so it
984
+ // established nothing, and a caller who asked for no network did not ask for a red exit (#218).
985
+ const cloudflare = report.cloudflare.state;
986
+ // `probe_failed` joins them on the same standard (#371): the probe threw, so it established nothing —
987
+ // and a credentials file that will not parse is not a credential this project has been shown to lack.
988
+ if (
989
+ cloudflare !== "ok" &&
990
+ cloudflare !== "unconfigured" &&
991
+ cloudflare !== "not_checked" &&
992
+ cloudflare !== "probe_failed"
993
+ ) {
994
+ return 1;
995
+ }
996
+ // Listed positively, never as "anything but ok": only a fault this project's own config or wiring
997
+ // positively establishes may gate CI. `unconfigured` (no name yet) and `could-not-check` (the wiring
998
+ // would not read) establish nothing, and failing on either would break every run in an unprovisioned
999
+ // project. `invalid` meets the same standard the other two do — the name is set, and no Cloudflare
1000
+ // namespace can carry it, which is why every other command already hard-fails on it.
1001
+ //
1002
+ // `drifted` and `orphaned` now meet it too, which they did not always: `drifted` used to fire on any one
1003
+ // declared name that merely had Pithy's shape, so an adopter's pre-existing `myapp-prod-db` — the ordinary
1004
+ // Cloudflare convention — turned a green CI red on the adoption path. Both are evidence-backed now.
1005
+ // `drifted` is a wholesale contradiction between this repo's own config and its own wiring, checkable
1006
+ // from files alone; `orphaned` is Pithy's own `pithy_migrations_owner` stamp naming another project. A
1007
+ // name that only *looks* like ours establishes nothing and no longer reaches either.
1008
+ //
1009
+ // A `null` check is the same standard once more: no readable config means nothing was established about
1010
+ // any name, and `doctor` outside a project — to read the CLI version, the shell, the alias — must exit 0.
1011
+ const state = report.projectName?.state;
1012
+ if (state === "invalid" || state === "drifted" || state === "orphaned") return 1;
1013
+ // Same standard once more, and it is met from local files alone: a Worker's directory and its own
1014
+ // wrangler.jsonc contradict each other about which Worker this is. Nothing is inferred about the
1015
+ // account, and `could-not-check` establishes nothing, so only `drifted` gates.
1016
+ if (report.workerNames?.state === "drifted") return 1;
1017
+ // Same standard again, and met the same way: the root config and a Worker's own wrangler.jsonc
1018
+ // contradict each other about which environments this project has. Nothing about the account is
1019
+ // inferred — an orphan is established by ids the checkout already commits — and `could-not-check`
1020
+ // establishes nothing, so only `drifted` gates.
1021
+ if (report.environments?.state === "drifted") return 1;
1022
+ // The same standard, and two of the three origin faults meet it. `workers-dev-open` is a Worker serving
1023
+ // a live origin its own config does not name, established from that config alone — and it is the
1024
+ // security half: on that origin the CSRF gate refuses the requests that establish who you are, and
1025
+ // nothing bound to the hostname applies. `unserved-origin` is the mirror image and the harder failure:
1026
+ // the config names an origin and nothing in it serves that host, so the Worker answers nowhere — which
1027
+ // is the state this command's own `workers_dev` remedy used to produce, then report as healthy (#264).
1028
+ // Both are established from the project's own files. `no-origin` is the state every freshly scaffolded
1029
+ // project is in before it has a domain, which is legitimate and universal; failing it would turn
1030
+ // `pithy doctor` red on day one for everyone and teach them to stop reading it. It is reported, loudly,
1031
+ // and `pithy deploy` is what refuses it — the moment it stops being hypothetical.
1032
+ if (report.origins?.drift.some((drift) => drift.fault !== "no-origin")) return 1;
1033
+ // The same standard, and **both** faults meet it — there is no day-one state here to spare. A project
1034
+ // that declares no Workflows has no drift to report; one that declares them and has not synced is a
1035
+ // contradiction between its own two files, checkable offline, and the only fault in this report whose
1036
+ // consequence is nothing happening at all: the cron never fires and nothing anywhere says so (#267).
1037
+ // An unwritable declaration is the same standard once more — the declaration itself is what no command
1038
+ // can act on. `could-not-check` establishes nothing and carries no drift, so it never reaches here.
1039
+ if (report.workflows && report.workflows.drift.length > 0) return 1;
1040
+ // And once more, on a file rather than a config. A `dev.json` that will not parse or names no user is a
1041
+ // fault this machine's own disk establishes: the file is there, and nothing will ever read anything out of
1042
+ // it. `absent` is the documented default — no file, no session, magic links only — so it never gates, and
1043
+ // CI (which has no `dev.json` at all) is therefore never touched by this check. The audience is the
1044
+ // developer whose dev login stopped working, which is the audience the whole check exists for.
1045
+ const preferences = report.devPreferences?.state;
1046
+ if (preferences === "unparseable" || preferences === "no-user") return 1;
1047
+ // The same standard as everything above it, and **both tiers meet it** (#411). A local finding is
1048
+ // established from the project's own config through the capability's own schema. An account finding is
1049
+ // established from an account that answered — which is exactly why the list is what gates and the
1050
+ // tier's state is not: an account nobody reached contributes no findings, so a run that skipped it
1051
+ // cannot fail here. `could-not-check` and `unchecked` establish nothing and never reach this line.
1052
+ if (report.settings && report.settings.findings.length > 0) return 1;
1053
+ return report.project && !report.project.health.ok ? 1 : 0;
1054
+ }
1055
+
1056
+ /** Abbreviate a home-relative path to `~/…` for display. */
1057
+ function tildify(path: string, home: string): string {
1058
+ return path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
1059
+ }
1060
+
1061
+ /**
1062
+ * The width of the paths block's label field — `Config dir: `, `Secrets: `, `Ports: `, all twelve.
1063
+ *
1064
+ * Named because the port listing indents under it, and a continuation that is off by a space reads as a
1065
+ * different block. Derived from the longest label rather than typed as twelve, so a longer one moves both.
1066
+ */
1067
+ const PATHS_INDENT = " ".repeat("Config dir: ".length);
1068
+
1069
+ /**
1070
+ * The `Ports:` line's listing — one row per allocated block, this checkout's first (#436).
1071
+ *
1072
+ * **Ranges, not block indices.** The index is the registry's own key and is the one form of this fact
1073
+ * nobody can act on: a registry written before `BLOCK_SIZE` went to 20 holds entries of both widths, so
1074
+ * block 2 and block 4 do not mean what their numbers imply, and the port a developer is actually looking
1075
+ * at — the one `pithy dev` refused to bind — appears in neither. Printing the range is what makes a
1076
+ * mixed-width registry legible, and it is the form the question arrives in.
1077
+ *
1078
+ * Own rows are unqualified because there is nothing to disambiguate; every other row names the checkout
1079
+ * that holds it, which is the whole answer to "8847, and I don't know why". `← not on disk` trails the
1080
+ * one row a developer can do something about — see {@link PortsRegistryEntry.onDisk}.
1081
+ */
1082
+ function portsRegistryRows(check: PortsRegistryCheck, home: string): string[] {
1083
+ const range = (entry: PortsRegistryEntry): string => `${entry.base}–${entry.base + entry.size - 1}`;
1084
+ const width = Math.max(0, ...check.entries.map((entry) => range(entry).length));
1085
+ return check.entries.map((entry) => {
1086
+ const held = entry.own ? entry.branch : `${tildify(entry.root, home)} — ${entry.branch}`;
1087
+ return `${range(entry).padEnd(width)} ${held}${entry.onDisk ? "" : " ← not on disk"}`;
1088
+ });
1089
+ }
1090
+
1091
+ /**
1092
+ * The `Alias:` line, in all three states.
1093
+ *
1094
+ * The third one **names the file and nothing else about it** (#210). The reason `readRcFile` raised is
1095
+ * carried in `--json`, where a script can read it; here the file is the whole actionable fact, and it is
1096
+ * tilde-abbreviated like every other path in this report. What the line must never do is offer
1097
+ * `pithy alias` — that command reads the same file and fails on it — so it names the order the two things
1098
+ * have to happen in.
1099
+ */
1100
+ function aliasLine(alias: AliasStatus, terse: boolean, home: string): string {
1101
+ switch (alias.state) {
1102
+ case "installed":
1103
+ return terse ? "Alias: installed" : "Alias: installed (`p.` → `pithy`)";
1104
+ case "not-installed":
1105
+ return terse ? "Alias: not installed" : "Alias: not installed (run `pithy alias`)";
1106
+ case "unknown":
1107
+ return `Alias: unknown — can't read ${tildify(alias.rcPath ?? "your shell config", home)}. Fix that first; \`pithy alias\` reads the same file.`;
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Health lines nest under their Worker's name: a four-space indent plus a 13-wide label column, so a check's
1113
+ * content aligns at column 17 and its continuation lines sit flush beneath (docs/CLI.md §5.6).
1114
+ */
1115
+ const HEALTH_LABEL = 13;
1116
+ const HEALTH_INDENT = " ".repeat(4);
1117
+ const HEALTH_CONT = " ".repeat(4 + HEALTH_LABEL);
1118
+ function healthLine(label: string, content: string): string {
1119
+ return `${HEALTH_INDENT}${label.padEnd(HEALTH_LABEL)}${content}`;
1120
+ }
1121
+
1122
+ /**
1123
+ * The `migrations` check's lines, in every state its ledger can be in (#371).
1124
+ *
1125
+ * **A database that could not be read gets its own sentence, and it is not "0 pending".** That was the
1126
+ * fault: an unreachable D1 contributed nothing to the sum, so the line said the schema was level with the
1127
+ * project when nothing had compared them. The unread databases are named — the only actionable fact — and
1128
+ * nothing derived from what the read threw appears, because a D1 failure's own words name an id or a query.
1129
+ */
1130
+ function migrationLines(health: MigrationHealth): string[] {
1131
+ const lines: string[] = [];
1132
+ const ledger = health.ledger;
1133
+ if (ledger.state === "unavailable") {
1134
+ lines.push(healthLine("migrations", "couldn't be checked — no database in scope answered"));
1135
+ lines.push(`${HEALTH_CONT}The schema may be behind or ahead; this run established neither.`);
1136
+ return lines;
1137
+ }
1138
+ const counted = ledger.state === "read" ? ledger : ledger.counted;
1139
+ if (counted.pending > 0) {
1140
+ lines.push(healthLine("migrations", `${counted.pending} pending — run: pithy migrate --env ${health.env}`));
1141
+ }
1142
+ // The other direction, and the one nothing reported until #282. It is not "N pending" with a
1143
+ // different number: nothing is pending, migrate refuses outright, and the remedy is neither `pithy
1144
+ // migrate` nor `pithy upgrade`. So it gets its own sentence, written once in `migrations/ledger.ts`
1145
+ // and printed here exactly as `pithy migrate` refuses with it — two commands, one wording.
1146
+ if (counted.undeclared.length > 0) {
1147
+ lines.push(healthLine(lines.length === 0 ? "migrations" : "", describeUndeclared(counted.undeclared)));
1148
+ lines.push(`${HEALTH_CONT}${undeclaredRemedy(health.env)}`);
1149
+ }
1150
+ if (ledger.state === "partial") {
1151
+ const named = ledger.unreadable.map((entry) => `${entry.binding} (${entry.database})`).join(", ");
1152
+ lines.push(healthLine(lines.length === 0 ? "migrations" : "", `couldn't read ${named}`));
1153
+ lines.push(`${HEALTH_CONT}Every number above counts the databases that answered, and not those.`);
1154
+ }
1155
+ return lines;
1156
+ }
1157
+
1158
+ /** One Worker's five check lines. Every check is shown, so a passing one still reads as checked. */
1159
+ /**
1160
+ * The `bindings` lines for a Worker's declined optional bindings.
1161
+ *
1162
+ * Four states, four sentences, in the house shape: state, then the cause in a comma clause, then the
1163
+ * consequence. Each honored decline gets a continuation line rather than a longer first line, because
1164
+ * the reason is the adopter's own text and a fixed-width report cannot budget for it.
1165
+ *
1166
+ * The two refusals name what to do; the stale one names both ways out, because either is correct and
1167
+ * only the adopter knows which they meant.
1168
+ */
1169
+ /** Whether any Worker in the project has something to say about a declined binding. */
1170
+ function projectHasDeclines(health: ProjectHealth): boolean {
1171
+ return health.workers.some((worker) => worker.state !== "unavailable" && hasDeclines(worker));
1172
+ }
1173
+
1174
+ /** Whether a Worker's checks carry anything to say about a declined binding. */
1175
+ function hasDeclines(worker: WorkerChecks): boolean {
1176
+ const declines = worker.bindings.declinedBindings;
1177
+ return declines.state === "invalid" || declines.declines.length > 0;
1178
+ }
1179
+
1180
+ function declineLines(declines: BindingDeclines): string[] {
1181
+ if (declines.state === "invalid") {
1182
+ return ["`declinedBindings` in pithy.config.ts cannot be read", `${HEALTH_CONT}${declines.problem}`];
1183
+ }
1184
+ return declines.declines.flatMap((decline) => {
1185
+ switch (decline.state) {
1186
+ case "honored":
1187
+ return [
1188
+ `${decline.name} (${decline.type}) declined in pithy.config.ts — ${decline.reason}`,
1189
+ `${HEALTH_CONT}${decline.capability} takes its optional path.${
1190
+ decline.stillPresentIn.length === 0
1191
+ ? ""
1192
+ : ` Still in wrangler.jsonc for ${decline.stillPresentIn.join(", ")}.`
1193
+ }`,
1194
+ ];
1195
+ case "required":
1196
+ return [
1197
+ `${decline.name} (${decline.type}) declined in pithy.config.ts, and ${decline.capability} requires it`,
1198
+ `${HEALTH_CONT}A required binding is never left out. Remove the line.`,
1199
+ ];
1200
+ case "undeclinable":
1201
+ // The second line has to fit the kind. It said "run provision" for a Durable Object, which is
1202
+ // wrong on both halves: no capability exposes a DO provision command, and the reason a Durable
1203
+ // Object is refused is the write-once class migration tag, not provisioning.
1204
+ return [
1205
+ `${decline.name} (${decline.type}) declined in pithy.config.ts, and this kind cannot be declined`,
1206
+ `${HEALTH_CONT}${undeclinableReason(decline.type)} ${
1207
+ decline.type === "durable_object"
1208
+ ? "Remove the line."
1209
+ : `Run \`pithy ${decline.capability} provision\`, or remove the line.`
1210
+ }`,
1211
+ ];
1212
+ case "unrecognized":
1213
+ return [
1214
+ `${decline.name} declined in pithy.config.ts, and nothing here declares it`,
1215
+ `${HEALTH_CONT}Nothing is being left out for it. Delete the line, or fix the name.`,
1216
+ ];
1217
+ }
1218
+ // Unreachable: the switch covers `BindingDecline` exhaustively. `satisfies never` is what keeps it
1219
+ // that way — a fifth decline state stops compiling here rather than silently printing nothing about
1220
+ // itself, which for a report whose whole purpose is not being silent would be the worst failure it
1221
+ // could have.
1222
+ decline satisfies never;
1223
+ return [];
1224
+ });
1225
+ }
1226
+
1227
+ function workerHealthLines(health: WorkerChecks): string[] {
1228
+ const lines: string[] = [];
1229
+
1230
+ // First, and above the rest, because it is the only one that means the Worker does not start. Binding
1231
+ // drift and a pending migration are things a running Worker has; a missing prerequisite is a
1232
+ // `createBackend` refusal at assembly, so every other line below it describes a Worker that is down.
1233
+ if (health.prerequisites.ok) {
1234
+ lines.push(healthLine("prereqs", "every composed capability has its peers ✓"));
1235
+ } else {
1236
+ health.prerequisites.missing.forEach((entry, index) => {
1237
+ lines.push(
1238
+ healthLine(
1239
+ index === 0 ? "prereqs" : "",
1240
+ `${entry.capability} requires ${entry.requires} — run: pithy add ${entry.requires}`,
1241
+ ),
1242
+ );
1243
+ });
1244
+ lines.push(`${HEALTH_CONT}This worker will not boot until they are composed.`);
1245
+ }
1246
+
1247
+ if (health.config.ok) {
1248
+ lines.push(healthLine("config", "parses against every capability schema ✓"));
1249
+ } else {
1250
+ const [first, ...rest] = health.config.drift;
1251
+ lines.push(healthLine("config", `options missing from pithy.config.ts — run \`pithy upgrade\``));
1252
+ if (first) lines.push(`${HEALTH_CONT}${first.capability}: ${first.keys.join(", ")}`);
1253
+ for (const cap of rest) lines.push(`${HEALTH_CONT}${cap.capability}: ${cap.keys.join(", ")}`);
1254
+ }
1255
+
1256
+ // **One accumulator, labeled by whether anything has been written yet.** Three lists share the
1257
+ // `bindings` label now, and the `index === 0 && previousList.length === 0` arithmetic that carried
1258
+ // two of them does not extend to a third — each new list would have to know the length of every list
1259
+ // before it. `bindingLine` asks the only question the label actually turns on.
1260
+ const bindingLines: string[] = [];
1261
+ const bindingLine = (text: string) =>
1262
+ bindingLines.push(healthLine(bindingLines.length === 0 ? "bindings" : "", text));
1263
+
1264
+ if (health.bindings.ok) {
1265
+ bindingLine("all required bindings present ✓");
1266
+ } else {
1267
+ for (const binding of health.bindings.missing) {
1268
+ bindingLine(`${binding.name} (${binding.type}) missing from wrangler.jsonc`);
1269
+ bindingLines.push(`${HEALTH_CONT}env: ${binding.envs.join(", ")}`);
1270
+ }
1271
+ // The other half of a Durable Object binding, and the half that lives in the adopter's code.
1272
+ // wrangler resolves `class_name` against the module `main` names, so a class missing there is a
1273
+ // deploy this project cannot make — reported under `bindings` because it is one binding written in
1274
+ // two files (#428).
1275
+ for (const className of health.bindings.missingExports) {
1276
+ bindingLine(`${className} not exported from this worker's entry — run \`pithy upgrade\``);
1277
+ }
1278
+ }
1279
+ // Declines print whether or not the checks above passed, and that is the whole point: a binding an
1280
+ // adopter deliberately left out is invisible on a green report otherwise, and the next person cannot
1281
+ // tell "chosen" from "never heard of it" (#440).
1282
+ for (const line of declineLines(health.bindings.declinedBindings)) {
1283
+ if (line.startsWith(HEALTH_CONT)) bindingLines.push(line);
1284
+ else bindingLine(line);
1285
+ }
1286
+ lines.push(...bindingLines);
1287
+
1288
+ if (health.migrations.ok) {
1289
+ lines.push(healthLine("migrations", "none pending, none undeclared ✓"));
1290
+ } else {
1291
+ lines.push(...migrationLines(health.migrations));
1292
+ }
1293
+
1294
+ if (health.entitlements.ok) {
1295
+ lines.push(healthLine("entitlements", "no gated route without a provider ✓"));
1296
+ } else if (health.entitlements.gap.state === "unavailable") {
1297
+ // Not "no gated route" — nothing was read, so nothing is known. #371's rule, said out loud.
1298
+ lines.push(healthLine("entitlements", "couldn't be checked — this worker's source would not scan"));
1299
+ } else {
1300
+ // Report-only: `pithy upgrade` cannot pick a capability for the adopter, so the line names the fix.
1301
+ lines.push(healthLine("entitlements", "gated routes, no provider — run: pithy add payments"));
1302
+ for (const gate of health.entitlements.gap.gates) lines.push(`${HEALTH_CONT}${gate}`);
1303
+ }
1304
+
1305
+ return lines;
1306
+ }
1307
+
1308
+ /**
1309
+ * The `Project health` lines — shown only when some Worker is failing a check, grouped one block per Worker.
1310
+ * A healthy Worker collapses to a single line: it was checked, and there is nothing to say about it.
1311
+ */
1312
+ function healthBlock(health: ProjectHealth): string {
1313
+ const lines = ["Project health:"];
1314
+ // First, and above the Workers, because it explains a hole in every one of their blocks: a capability
1315
+ // whose manifest could not be read contributes no drift to any check below it, so a project full of
1316
+ // them read as healthy and said nothing at all (#184). No Worker owns this — manifests resolve once,
1317
+ // from the project root — so it sits at the block's top rather than inside a Worker's section.
1318
+ if (!health.manifests.ok) {
1319
+ lines.push(" manifests:");
1320
+ for (const fault of health.manifests.faults) {
1321
+ // Not `healthLine`: a package name is longer than the 13-column label the per-Worker checks use, so
1322
+ // it takes the line and its reason indents beneath it.
1323
+ lines.push(
1324
+ `${HEALTH_INDENT}${fault.package}: malformed pithy.manifest.json — reinstall it, or tell its maintainer`,
1325
+ );
1326
+ for (const line of fault.reason.split("\n")) lines.push(`${HEALTH_INDENT} ${line}`);
1327
+ }
1328
+ }
1329
+ for (const worker of health.workers) {
1330
+ // Not "healthy", and not five empty checks either. Nothing was read about this Worker, so the block
1331
+ // says exactly that and names the two files a plan is built from (#371).
1332
+ if (worker.state === "unavailable") {
1333
+ lines.push(` ${worker.worker}: couldn't be checked`);
1334
+ lines.push(`${HEALTH_INDENT}Its pithy.config.ts or wrangler.jsonc would not read. Nothing below is about it.`);
1335
+ continue;
1336
+ }
1337
+ // A Worker with a decline is never collapsed to one line, even when every check passes. The
1338
+ // decline is the fact this Worker's operator most needs to see and the one nothing else records —
1339
+ // a `healthy ✓` here is how a deliberate absence becomes indistinguishable from a forgotten one,
1340
+ // which is the collapse #440 exists to remove.
1341
+ if (worker.ok && !hasDeclines(worker)) {
1342
+ lines.push(` ${worker.worker}: healthy ✓`);
1343
+ continue;
1344
+ }
1345
+ lines.push(` ${worker.worker}:`);
1346
+ lines.push(...workerHealthLines(worker));
1347
+ }
1348
+ return lines.join("\n");
1349
+ }
1350
+
1351
+ /**
1352
+ * The `Worker names` lines — shown only when a stamp contradicts its directory, grouped one block per
1353
+ * Worker, on the health block's shape. A Worker whose names agree says nothing at all: there is no
1354
+ * "names fine ✓" line, because unlike a health check this has no per-Worker section to sit in.
1355
+ */
1356
+ /**
1357
+ * The `Environments` lines — shown only when a Worker's stanzas and the declaration disagree, grouped one
1358
+ * block per Worker, on the health block's shape. A project whose Workers all agree says nothing at all.
1359
+ *
1360
+ * The declared set leads, because the whole finding is a comparison and a report that names only one side
1361
+ * of it makes the reader open the config to learn what it compared against.
1362
+ */
1363
+ function environmentsBlock(check: EnvironmentsCheck): string {
1364
+ const lines = [`Environments: ${check.declared.join(", ")}`];
1365
+ for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
1366
+ lines.push(` ${worker}:`);
1367
+ for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
1368
+ lines.push(healthLine(`env.${drift.env}`, describeEnvironmentDrift(drift, check.declared)));
1369
+ if (drift.resources.length > 0) lines.push(`${HEALTH_CONT}${drift.resources.join(", ")}`);
1370
+ }
1371
+ }
1372
+ // No command is offered, and that is the point: `pithy` cannot rename a Cloudflare resource, so applying
1373
+ // a changed declaration is exactly the thing that must never happen quietly. The two edits are named.
1374
+ lines.push(`${HEALTH_INDENT}Make environments in pithy.config.ts and each Worker's env.<name> stanzas agree.`);
1375
+ return lines.join("\n");
1376
+ }
1377
+
1378
+ /**
1379
+ * The `Origins` lines — shown only when an environment serves an origin its config does not name, grouped
1380
+ * one block per Worker, on the health block's shape (#253).
1381
+ *
1382
+ * The block offers no command of its own, on the same rule the environments block above states: which
1383
+ * hostname an environment answers on is the adopter's to declare, and whether the `workers.dev` subdomain
1384
+ * should stay open is a decision rather than a default. Each drift's own sentence names the edit and the
1385
+ * file it goes in — and exactly one of the three names a command, because exactly one has one: a route
1386
+ * missing from a `domains` declaration is derived, so `pithy worker sync` writes it (#264).
1387
+ */
1388
+ function originsBlock(check: OriginsCheck): string {
1389
+ const lines = ["Origins:"];
1390
+ for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
1391
+ lines.push(` ${worker}:`);
1392
+ for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
1393
+ lines.push(healthLine(`env.${drift.env}`, describeOriginDrift(drift)));
1394
+ }
1395
+ }
1396
+ return lines.join("\n");
1397
+ }
1398
+
1399
+ /**
1400
+ * The `Workflows:` lines — shown only when an environment's stanza does not bind what its Worker's app
1401
+ * capability declares (#267), grouped one block per Worker, on the same shape as the block above.
1402
+ *
1403
+ * One command answers every `unsynced-stanza` line in it, and each drift's own sentence names it — the
1404
+ * block adds no trailing remedy of its own, because the other fault has a different one: a declaration
1405
+ * that cannot be reduced to a stanza is fixed in `pithy.config.ts`, and no command can write it.
1406
+ */
1407
+ function workflowsBlock(check: WorkflowsCheck): string {
1408
+ const lines = ["Workflows:"];
1409
+ for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
1410
+ lines.push(` ${worker}:`);
1411
+ for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
1412
+ lines.push(healthLine(`env.${drift.env}`, describeWorkflowDrift(drift)));
1413
+ }
1414
+ }
1415
+ return lines.join("\n");
1416
+ }
1417
+
1418
+ /**
1419
+ * The `Capability extensions:` lines — what an adopter plugged into a capability, grouped per Worker.
1420
+ *
1421
+ * The only block in this report that is not a finding, and shown whenever there is anything to show
1422
+ * rather than only when something is wrong. That is deliberate: an extension is a deliberate act, so
1423
+ * there is no fault to report — and it is also the only place a composed Better Auth plugin has a name
1424
+ * outside the source of `pithy.config.ts`, which is what makes its absence from a report a problem and
1425
+ * its presence not one.
1426
+ */
1427
+ function extensionsBlock(check: ExtensionsCheck): string {
1428
+ const lines = ["Capability extensions:"];
1429
+ for (const worker of [...new Set(check.extensions.map((entry) => entry.worker))]) {
1430
+ lines.push(` ${worker}:`);
1431
+ for (const entry of check.extensions.filter((candidate) => candidate.worker === worker)) {
1432
+ lines.push(` ${describeExtension(entry)}`);
1433
+ }
1434
+ }
1435
+ return lines.join("\n");
1436
+ }
1437
+
1438
+ /**
1439
+ * The `Secret bindings:` lines — shown only when a declared environment does not bind a
1440
+ * `cf-secrets-store` secret its Worker reads (#238).
1441
+ *
1442
+ * Its own block rather than a line inside `Dev secrets:`, because it is about the opposite half of the
1443
+ * project: that block is the machine-local file, this is the stanza a *deployed* Worker boots against.
1444
+ * A single command answers every line in it, which is why the lines group per Worker-and-environment.
1445
+ */
1446
+ /**
1447
+ * The `Settings:` lines — whether each composed capability's settings work, grouped per Worker on the
1448
+ * health block's shape (#411).
1449
+ *
1450
+ * **It prints on a healthy project too, and in the terse form.** Every other finding block here is the
1451
+ * finding, so silence means nothing is wrong; this one has a third answer that silence cannot carry. An
1452
+ * account tier nobody could reach established nothing, and a report that said nothing about it would be
1453
+ * read as a pass — which is the one thing this check must never be. So a clean run collapses to one line
1454
+ * that says the checks ran, and any run that skipped the account says so out loud.
1455
+ *
1456
+ * A capability whose own check threw is named with the tier that failed, never folded into the findings:
1457
+ * "could not be run" is not "nothing to fix", and the exit gate reads only the findings.
1458
+ */
1459
+ function settingsBlock(check: SettingsCheck): string {
1460
+ // "The account was never asked" is a pass only when nothing wanted to ask it.
1461
+ const accountSettled = check.account.state === "checked" || check.account.reason === "not-declared";
1462
+ if (check.state === "ok" && accountSettled) return "Settings: every composed capability's settings work ✓";
1463
+
1464
+ const lines = ["Settings:"];
1465
+ const workers = [...new Set([...check.findings, ...check.unchecked].map((entry) => entry.worker))];
1466
+ // Nothing was established about anything, because the probe itself did not run. Said as exactly that:
1467
+ // the account line below would blame Cloudflare for a project that never got as far as asking it.
1468
+ if (workers.length === 0 && check.state === "could-not-check") {
1469
+ lines.push(`${HEALTH_INDENT}the checks could not be run, so nothing here was established`);
1470
+ return lines.join("\n");
1471
+ }
1472
+ for (const worker of workers) {
1473
+ lines.push(` ${worker}:`);
1474
+ for (const finding of check.findings.filter((entry) => entry.worker === worker)) {
1475
+ lines.push(healthLine(finding.capability, settingsProblem(finding)));
1476
+ // The action on its own line beneath the problem, which is the shape every PithyError renders in.
1477
+ lines.push(`${HEALTH_CONT}${finding.action}`);
1478
+ }
1479
+ for (const entry of check.unchecked.filter((candidate) => candidate.worker === worker)) {
1480
+ lines.push(healthLine(entry.capability, `${entry.tier} checks couldn't be run`));
1481
+ }
1482
+ }
1483
+ if (!accountSettled) lines.push(`${HEALTH_INDENT}${describeSettingsAccount(check.account)}.`);
1484
+ return lines.join("\n");
1485
+ }
1486
+
1487
+ /**
1488
+ * The `Local delivery:` lines — whether a message sent from this machine leaves it (#410).
1489
+ *
1490
+ * Every line is {@link deliveryPreflight}'s own, verbatim, because `pithy dev` decides with the same
1491
+ * call and the two must not word one verdict twice. Doctor adds the heading and nothing else.
1492
+ *
1493
+ * It prints in the terse report as well, and never fails the exit. The simulator is a legitimate
1494
+ * choice rather than a fault — but a report that said nothing would be read as "of course it sends",
1495
+ * which is the assumption that had people waiting on an inbox no local process could have posted to.
1496
+ */
1497
+ function localDeliveryBlock(check: LocalDeliveryCheck): string {
1498
+ return ["Local delivery:", ...check.lines.map((line) => ` ${line}`)].join("\n");
1499
+ }
1500
+
1501
+ /** One finding's problem line: what is wrong, where, and why — the action follows it. */
1502
+ function settingsProblem(finding: SettingsFindingEntry): string {
1503
+ const where = finding.environment === null ? "" : ` (${finding.environment})`;
1504
+ return `${finding.setting}${where} — ${finding.problem}`;
1505
+ }
1506
+
1507
+ function secretBindingsBlock(check: SecretBindingsCheck): string {
1508
+ return ["Secret bindings:", ...describeSecretBindings(check).map((line) => ` ${line}`)].join("\n");
1509
+ }
1510
+
1511
+ function workerNamesBlock(check: WorkerNameCheck): string {
1512
+ const lines = ["Worker names:"];
1513
+ const workers = [...new Set(check.mismatches.map((mismatch) => mismatch.worker))];
1514
+ for (const worker of workers) {
1515
+ lines.push(` ${worker}:`);
1516
+ for (const mismatch of check.mismatches.filter((entry) => entry.worker === worker)) {
1517
+ lines.push(healthLine(mismatch.stamp, describeWorkerName(mismatch)));
1518
+ if (mismatch.envs.length > 0) lines.push(`${HEALTH_CONT}env: ${mismatch.envs.join(", ")}`);
1519
+ }
1520
+ }
1521
+ // No command is offered to fix this one, because none of them can: the directory has already moved, and
1522
+ // `pithy worker rename` refuses a destination that exists. The fix is the two edits named above. The
1523
+ // command is named anyway, for the next rename — it moves all three at once and this block stays empty.
1524
+ lines.push(`${HEALTH_INDENT}Make wrangler.jsonc agree with the directory. Next time: pithy worker rename.`);
1525
+ return lines.join("\n");
1526
+ }
1527
+
1528
+ /**
1529
+ * The `Project capabilities` lines — collapsed to one line when everything is current.
1530
+ *
1531
+ * The unchecked line says *why* it is unchecked. `unknown` means the registry did not answer on an
1532
+ * ordinary run and that nobody asked it on an offline one, and blaming a network that was never touched is
1533
+ * the sort of wrong a diagnostic is judged on.
1534
+ */
1535
+ function capabilitiesBlock(capabilities: CapabilityStatus[], offline: boolean): string {
1536
+ if (capabilities.length === 0 || capabilities.every((cap) => cap.state === "current")) {
1537
+ return "Project capabilities: all up to date";
1538
+ }
1539
+ // Nothing is behind, but nothing was confirmed either — say which, rather than implying currency.
1540
+ if (capabilities.every((cap) => cap.state !== "outdated")) {
1541
+ return offline
1542
+ ? "Project capabilities: version check skipped (offline)"
1543
+ : "Project capabilities: version check unavailable (registry unreachable)";
1544
+ }
1545
+ const width = Math.max(...capabilities.map((cap) => cap.name.length));
1546
+ const rows = capabilities.map((cap) => {
1547
+ const suffix =
1548
+ cap.state === "current"
1549
+ ? " ✓"
1550
+ : cap.state === "unknown"
1551
+ ? " (not checked)"
1552
+ : ` (${cap.latest} available — run \`pithy upgrade\`)`;
1553
+ return ` ${cap.name.padEnd(width)} ${cap.installed}${suffix}`;
1554
+ });
1555
+ return ["Project capabilities:", ...rows].join("\n");
1556
+ }
1557
+
1558
+ /** Render the report as the aligned, blocked text of docs/CLI.md §5.6. Verbose vs. terse driven by overall health. */
1559
+ export function renderDoctorText(report: DoctorReport, home = process.env.HOME ?? ""): string {
1560
+ const capsUpToDate = !report.project || report.project.capabilities.every((cap) => cap.state === "current");
1561
+ const healthOk = !report.project || report.project.health.ok;
1562
+ const cloudflareOk = report.cloudflare.state === "ok";
1563
+ // A name that was never asked about is not a name that failed. Someone running `doctor` outside a project
1564
+ // is asking about their toolchain, and dragging the whole report verbose to explain a project they do not
1565
+ // have would answer a question they did not put. `unconfigured` still forces verbose — there the file is
1566
+ // real and a key is missing from it, which is worth the ink.
1567
+ const projectNameOk = report.projectName === null || report.projectName.state === "ok";
1568
+ // `could-not-check` keeps its silence here rather than forcing verbose: unlike an unreadable name, an
1569
+ // unreadable `wrangler.jsonc` is already the health block's line to say, and it says it louder.
1570
+ const workerNamesOk = !report.workerNames || report.workerNames.mismatches.length === 0;
1571
+ // Same silence for `could-not-check` and the same reason: an unreadable config is the `Project:` block's
1572
+ // line, and a second block repeating it is how a report starts contradicting itself.
1573
+ const environmentsOk = !report.environments || report.environments.drift.length === 0;
1574
+ // Both origin faults keep the report verbose, even though only one of them fails the exit. An
1575
+ // environment with no origin is the thing `pithy deploy` will refuse, and a report that stayed silent
1576
+ // about it would send the adopter to that refusal with no warning — which is exactly what #253 asked
1577
+ // doctor to stop doing. Worth the ink, not worth a red CI.
1578
+ const originsOk = !report.origins || report.origins.drift.length === 0;
1579
+ // Worth the ink and worth a red CI both, unlike the origins block above: this is the fault whose whole
1580
+ // symptom is that nothing happens, so a terse report over it would be the toolchain agreeing that
1581
+ // nothing is wrong.
1582
+ const workflowsOk = !report.workflows || report.workflows.drift.length === 0;
1583
+ // A dev login is optional, so having none is a pass — the terse report is for the developer who has
1584
+ // nothing to fix, and "you could have a dev login" is not something to fix. Only the two faults speak up.
1585
+ const devPreferencesState = report.devPreferences?.state;
1586
+ const devPreferencesOk = devPreferencesState !== "unparseable" && devPreferencesState !== "no-user";
1587
+ // A misplaced secret keeps the report verbose without failing the exit: it is worth the ink, and it is
1588
+ // the state every project that predates the dev secrets file starts in. A *missing* one does not — the
1589
+ // four OAuth pairs auth declares are unset in almost every project, and treating that as a fault would
1590
+ // drag every report in the world verbose. `devSecretsHealthy` draws that line; the block still prints.
1591
+ // A probe that could not run keeps the report verbose, on the same rule `Alias: unknown` follows:
1592
+ // "I could not check" is information, and the terse form is the report saying there is nothing to
1593
+ // look at (#371). It still never fails the exit.
1594
+ const devSecretsCheck = reported(report.devSecrets);
1595
+ const devSecretsOk = report.devSecrets === null || (devSecretsCheck !== null && devSecretsHealthy(devSecretsCheck));
1596
+ // A deployed Worker that will answer every request with a missing-binding error is worth the ink for
1597
+ // the same reason, and it does not fail the exit for the same reason either — see
1598
+ // {@link DoctorReport.secretBindings}.
1599
+ const secretBindingsOk = !report.secretBindings || report.secretBindings.missing.length === 0;
1600
+ // A setting that does not work keeps the report verbose, and a check nobody could run does too — the
1601
+ // rule `Alias: unknown` follows. `state` carries both, and it carries the third case the two lists
1602
+ // cannot: a whole probe that threw establishes nothing while listing nothing. A *skipped account tier* deliberately does not: that line prints in
1603
+ // both forms, like `Secrets:` and `Cloudflare:`, so the terse report can stay terse without ever
1604
+ // implying the account was checked.
1605
+ const settingsOk = !report.settings || report.settings.state === "ok";
1606
+ // A Worker that would start with no bindings at all is worth the ink for exactly the same reason a
1607
+ // misplaced secret is, and more so. It does not fail the exit — see {@link DoctorReport.devVars} —
1608
+ // but a report that called this project healthy is what #178 was reported about.
1609
+ const devVarsCheck = reported(report.devVars);
1610
+ const devVarsOk = report.devVars === null || (devVarsCheck !== null && devVarsHealthy(devVarsCheck));
1611
+ // An alias nobody could read keeps the report verbose, on the same rule the `unknown` version state
1612
+ // follows: "I could not check" is information, and the terse form is the report saying there is nothing
1613
+ // to look at. It still never fails the exit — toolchain state does not (#210).
1614
+ const aliasOk = report.alias.state !== "unknown";
1615
+ // An unknown keeps the report verbose on purpose: "I could not check" is information worth surfacing.
1616
+ const terse =
1617
+ report.cli.state === "current" &&
1618
+ capsUpToDate &&
1619
+ healthOk &&
1620
+ cloudflareOk &&
1621
+ projectNameOk &&
1622
+ workerNamesOk &&
1623
+ environmentsOk &&
1624
+ originsOk &&
1625
+ workflowsOk &&
1626
+ devPreferencesOk &&
1627
+ devSecretsOk &&
1628
+ secretBindingsOk &&
1629
+ settingsOk &&
1630
+ devVarsOk &&
1631
+ aliasOk &&
1632
+ !report.projectLoadError;
1633
+
1634
+ const blocks: string[] = [];
1635
+
1636
+ /**
1637
+ * The `Secrets:` line's content — the path, plus whatever {@link describeDevSecretsLocation} has to add.
1638
+ *
1639
+ * Built once and rendered twice, because it is the one line in the report that belongs in **both**
1640
+ * forms. Everything else here reports a fault, and the terse report exists to say nothing when nothing
1641
+ * is wrong; this reports a *location*, and "where is the file" is not a complaint. The file is outside
1642
+ * every checkout since #156 — nothing in the project names it and `ls` will not find it — so a terse
1643
+ * report that omitted it left the adopter with no way to find it at all (#166). `null` outside a
1644
+ * project with a resolvable name, where there is no path to name.
1645
+ *
1646
+ * **It names the command as well as the path**, on the same rule as `Alias: not installed (run `pithy
1647
+ * alias`)`. Knowing where a file is is not the same as having a way to open it: this one is outside the
1648
+ * checkout, so no editor's file tree reaches it and no `ls` in the project finds it. The line was the
1649
+ * one place an adopter learned the path, and the path was all it gave — leaving "resolve it yourself
1650
+ * and open it" as the workflow. `pithy secrets edit` (#157) is that step, and this is the only line in
1651
+ * the toolchain positioned to mention it.
1652
+ */
1653
+ const secretsFile = reported(report.devSecretsFile);
1654
+ const secretsLocation =
1655
+ report.devSecretsFile === null
1656
+ ? null
1657
+ : secretsFile === null
1658
+ ? // Not a path, because none was resolved. Saying nothing would be the report implying there is
1659
+ // no file to find, which is the one thing this line exists to prevent (#371).
1660
+ "couldn't be checked"
1661
+ : (() => {
1662
+ const detail = describeDevSecretsLocation(secretsFile);
1663
+ const path = tildify(secretsFile.path, home);
1664
+ return `${path} (run \`pithy secrets edit\`)${detail ? ` — ${detail}` : ""}`;
1665
+ })();
1666
+
1667
+ /**
1668
+ * The `Cloudflare:` line, built once and rendered in **both** forms — the same rule `Secrets:` follows,
1669
+ * and for the same reason.
1670
+ *
1671
+ * It names the credentials file this run resolved, and "which account am I about to deploy to" is not a
1672
+ * complaint: it is a location, so it does not belong behind the not-healthy predicate. A machine holding
1673
+ * `cloudflare.leed.json` beside `cloudflare.other-co.json` cannot answer it any other way, and the run
1674
+ * that most needs the answer — everything green, about to deploy — was the one run that omitted it (#206).
1675
+ */
1676
+ const cloudflareLine = `Cloudflare: ${describeCloudflareAccess(report.cloudflare, home)}`;
1677
+
1678
+ // CLI version.
1679
+ const cliLines = [`pithy ${report.cli.installed} (installed via ${report.cli.installer})`];
1680
+ if (report.cli.state === "outdated" && report.cli.latest) {
1681
+ cliLines.push(`Update available: ${report.cli.latest}`);
1682
+ cliLines.push(`Run: ${report.cli.upgradeCommand}`);
1683
+ } else if (report.cli.state === "unknown") {
1684
+ // Same distinction the capabilities line draws, and it is drawn here first because this is the line
1685
+ // everybody reads: skipped is a decision somebody made, unreachable is a network that failed.
1686
+ cliLines.push(
1687
+ report.offline ? "Version check skipped (offline)." : "Version check unavailable (registry unreachable).",
1688
+ );
1689
+ } else {
1690
+ cliLines.push("Up to date.");
1691
+ }
1692
+ blocks.push(cliLines.join("\n"));
1693
+
1694
+ // Shell / alias.
1695
+ const shellName = report.shell ? report.shell.kind : "unknown";
1696
+ const shellLine =
1697
+ terse || !report.shell ? `Shell: ${shellName}` : `Shell: ${shellName} (${tildify(report.shell.rcPath, home)})`;
1698
+ blocks.push([shellLine, aliasLine(report.alias, terse, home)].join("\n"));
1699
+
1700
+ // Config / State / Notifier — verbose only.
1701
+ if (!terse) {
1702
+ let notifier: string;
1703
+ if (report.notifierEnabled) {
1704
+ notifier = "enabled (PITHY_NO_UPDATE_NOTIFIER to disable)";
1705
+ } else if (report.notifierDisabledBy === "env") {
1706
+ notifier = "disabled (PITHY_NO_UPDATE_NOTIFIER set)";
1707
+ } else {
1708
+ notifier = "disabled (pithy doctor --enable-notifier to re-enable)";
1709
+ }
1710
+ // The dev-login line belongs in this block and nowhere else. It is the same question the two lines above
1711
+ // it answer — where does the CLI keep this, and what is in it — and it is here because it used not to be
1712
+ // resolvable from them: `dev.json` lived under a second, unrelated config root, so this block named a
1713
+ // directory that did not contain it. One root, one block.
1714
+ const paths = [`Config dir: ${tildify(report.configDir, home)}`, `State file: ${tildify(report.stateFile, home)}`];
1715
+ if (report.devPreferences?.state === "could-not-check") {
1716
+ // No path, because none was resolved — resolving it is what failed. Naming the config directory
1717
+ // here would be a claim about a file this run never located (#371, #131's rule).
1718
+ paths.push(`Dev login: ${describeDevPreferences(report.devPreferences)}`);
1719
+ } else if (report.devPreferences) {
1720
+ const path = tildify(report.devPreferences.path, home);
1721
+ paths.push(`Dev login: ${path} — ${describeDevPreferences(report.devPreferences)}`);
1722
+ }
1723
+ // Beside the other two paths rather than in the findings block below, because it answers the same
1724
+ // question they do. The terse report has no paths block to sit in, so it gets the line on its own,
1725
+ // in this same position — see `secretsLocation` above.
1726
+ if (secretsLocation !== null) paths.push(`Secrets: ${secretsLocation}`);
1727
+ // The same block once more, and machine-wide like `State file:` rather than per project like
1728
+ // `Dev login:`. A registry that is simply there gets its path and no verdict — the path is the line.
1729
+ // The listing under it is the rest of the answer (#436): the path said *where*, and left *why is this
1730
+ // project on 8847* to `cat`. Both halves are in the report people already run rather than behind a
1731
+ // second command, because the defect being fixed is that this file is invisible, and a command nobody
1732
+ // knows exists is not more visible than a line here.
1733
+ if (report.portsRegistry) {
1734
+ const detail = describePortsRegistry(report.portsRegistry);
1735
+ const path = tildify(report.portsRegistry.path, home);
1736
+ paths.push(`Ports: ${path}${detail ? ` — ${detail}` : ""}`);
1737
+ for (const row of portsRegistryRows(report.portsRegistry, home)) paths.push(`${PATHS_INDENT}${row}`);
1738
+ }
1739
+ blocks.push([...paths, `Notifier: ${notifier}`].join("\n"));
1740
+ } else if (secretsLocation !== null) {
1741
+ // Unpadded, because there is nothing here to align it against: the terse report carries this line
1742
+ // and no other path. Same position in the report either way, so the two forms read as one document.
1743
+ blocks.push(`Secrets: ${secretsLocation}`);
1744
+ }
1745
+
1746
+ // Project. Three states across two fields, and all three are said out loud: the config loaded, it is
1747
+ // present but would not load, or there is none here. The third used to print nothing while the
1748
+ // `Project name:` line spoke for it — and got it wrong, advising a key be added to a file that did not
1749
+ // exist. Stated here, once, by the block whose subject it is.
1750
+ if (report.projectLoadError) {
1751
+ blocks.push(["Project: pithy.config.ts found", ` could not load — ${report.projectLoadError}`].join("\n"));
1752
+ } else if (report.project) {
1753
+ blocks.push(
1754
+ ["Project: pithy.config.ts found", capabilitiesBlock(report.project.capabilities, report.offline)].join("\n"),
1755
+ );
1756
+ // Or when a Worker declines something. The block is the only place a decline is reported, and a
1757
+ // project whose every check passes is exactly the project a decline is working on — gating the
1758
+ // block on `ok` alone would print the feature's output on every report except the ones it is for.
1759
+ if (!report.project.health.ok || projectHasDeclines(report.project.health)) {
1760
+ blocks.push(healthBlock(report.project.health));
1761
+ }
1762
+ } else {
1763
+ blocks.push("Project: no pithy.config.ts here — run `pithy init`, or change to a project directory");
1764
+ }
1765
+
1766
+ // Cloudflare credentials, on every run. See `cloudflareLine` above for why this one is not gated on
1767
+ // the report having something to complain about.
1768
+ blocks.push(cloudflareLine);
1769
+
1770
+ // The project name, reconciled against what is provisioned. Its own block rather than a second
1771
+ // Cloudflare line: the credentials answer "can I reach the account", this answers "is what I would
1772
+ // find there still mine". Absent when the config could not be read — the `Project:` line above has
1773
+ // already said so, and this line has no name to reconcile.
1774
+ if (!terse && report.projectName) blocks.push(`Project name: ${describeProjectName(report.projectName)}`);
1775
+
1776
+ // The Workers' own names, and only when they disagree. A Worker whose three stamps agree has nothing to
1777
+ // report — the block is the finding, the way `Project health` is.
1778
+ if (report.workerNames && report.workerNames.mismatches.length > 0) {
1779
+ blocks.push(workerNamesBlock(report.workerNames));
1780
+ }
1781
+
1782
+ // The environment declaration, and only when a Worker disagrees with it. The block is the finding.
1783
+ if (report.environments && report.environments.drift.length > 0) {
1784
+ blocks.push(environmentsBlock(report.environments));
1785
+ }
1786
+
1787
+ // Where each declared environment answers, and only when one of them serves an origin nothing named.
1788
+ // Beside the environments block because it is the same argument one level down: an environment is
1789
+ // declared, and now what is true about it must be too.
1790
+ if (report.origins && report.origins.drift.length > 0) blocks.push(originsBlock(report.origins));
1791
+
1792
+ // And what each declared environment runs, when its stanza does not bind what the app declares. Beside
1793
+ // the origins block for the same reason that one sits beside the environments block: the same argument
1794
+ // about the same environment, one declaration further in.
1795
+ if (report.workflows && report.workflows.drift.length > 0) blocks.push(workflowsBlock(report.workflows));
1796
+
1797
+ // And what an adopter plugged into a capability. Beside the blocks above because it is the same
1798
+ // subject — what this project actually composes — and unlike them it prints when there is nothing
1799
+ // wrong, because there is nothing here that can be wrong. Terse runs skip it: it is not a fault, and
1800
+ // `--terse` is the form that reports only faults.
1801
+ if (!terse && report.extensions && report.extensions.extensions.length > 0) {
1802
+ blocks.push(extensionsBlock(report.extensions));
1803
+ }
1804
+
1805
+ // Dev secrets, and only when something is wrong with them. The block is the finding: a project whose
1806
+ // secrets are in the file they belong in needs no line saying so, and every project that predates
1807
+ // the dev secrets file needs one every run until it moves them. Nothing here fails the exit — see
1808
+ // {@link DoctorReport.devSecrets}.
1809
+ if (report.devSecrets || report.devVars || report.devVarsLocal) {
1810
+ // A probe that could not run says so rather than contributing nothing (#371). Silence here is the
1811
+ // same sentence a clean file produces, and this block is read by somebody whose dev environment is
1812
+ // already not working.
1813
+ const unchecked = (label: string, value: Checked<unknown> | null): string[] =>
1814
+ value?.state === "could-not-check" ? [`${label} couldn't be checked.`] : [];
1815
+ const devVars = reported(report.devVars);
1816
+ const devSecrets = reported(report.devSecrets);
1817
+ const devVarsLocal = reported(report.devVarsLocal);
1818
+ const lines = [
1819
+ // First in the block, because it is the loudest thing there is to say about a dev environment:
1820
+ // that Worker answers every request with a missing-binding error, and the lines below it are
1821
+ // usually why. Everything else here is a value in the wrong file; this one is a Worker with none.
1822
+ ...(devVars ? describeDevVars(devVars) : unchecked(".dev.vars:", report.devVars)),
1823
+ ...(devSecrets ? describeDevSecrets(devSecrets) : unchecked("secrets.jsonc:", report.devSecrets)),
1824
+ // In the same block, because it is the same question asked of the file beside it: what is in a
1825
+ // git-ignored file that nothing else in the project knows about.
1826
+ ...(devVarsLocal ? describeDevVarsLocal(devVarsLocal) : unchecked(".dev.vars.local:", report.devVarsLocal)),
1827
+ ];
1828
+ if (lines.length > 0) blocks.push(["Dev secrets:", ...lines.map((line) => ` ${line}`)].join("\n"));
1829
+ }
1830
+
1831
+ // The same registry asked about the deployed environments. After `Dev secrets:` because a developer
1832
+ // reads this report about the project in front of them first, and a deploy second.
1833
+ if (report.secretBindings && report.secretBindings.missing.length > 0) {
1834
+ blocks.push(secretBindingsBlock(report.secretBindings));
1835
+ }
1836
+
1837
+ // And whether the values in all of that actually work. Last of the project blocks because it is the
1838
+ // only one that reaches past the checkout — it asks the account the block above it only asks a file
1839
+ // about — and it prints in both forms of the report, for the reason {@link settingsBlock} states.
1840
+ if (report.settings) blocks.push(settingsBlock(report.settings));
1841
+
1842
+ // And whether anything this project sends would leave this machine at all — the question `Settings:`
1843
+ // does not ask, because it is about the machine rather than about a value.
1844
+ if (report.localDelivery) blocks.push(localDeliveryBlock(report.localDelivery));
1845
+
1846
+ // OS / runtime. Named explicitly, because under Bun `report.node` is an emulated compatibility level
1847
+ // rather than the interpreter — reporting it alone would name a runtime that is not running.
1848
+ const runtime =
1849
+ report.runtime.nodeCompat === null
1850
+ ? `${report.runtime.name} ${report.runtime.version}`
1851
+ : `${report.runtime.name} ${report.runtime.version} (Node ${report.runtime.nodeCompat} compat)`;
1852
+ blocks.push([`OS: ${report.os.name} ${report.os.version}`, `Runtime: ${runtime}`].join("\n"));
1853
+
1854
+ return `\n${blocks.join("\n\n")}`;
1855
+ }
1856
+
1857
+ /** The `--json` mirror of every block (agents can't read aligned columns). Health failures still drive the exit. */
1858
+ export function renderDoctorJson(report: DoctorReport): Record<string, unknown> {
1859
+ return {
1860
+ cli: report.cli,
1861
+ shell: report.shell?.kind ?? null,
1862
+ // An object rather than the string it used to be, because the answer is tri-state now and a third
1863
+ // string would leave a script no way to reach the file the third state is about (#210). `state` is
1864
+ // `installed`, `not-installed`, or `unknown`; `rcPath` is the file, absolute here like every other
1865
+ // path in this payload; `reason` is the refusal's own sentence, and `null` on the two known states.
1866
+ alias: report.alias,
1867
+ configDir: report.configDir,
1868
+ stateFile: report.stateFile,
1869
+ notifier: report.notifierEnabled ? "enabled" : "disabled",
1870
+ // Whether this run refused ambient credentials and the network (#218). Unconditional like every key
1871
+ // here, and a boolean rather than an absence, because the question a script asks of this payload is
1872
+ // "was that check actually run" — and `false` is as much of an answer as `true`.
1873
+ offline: report.offline,
1874
+ project: report.projectLoadError
1875
+ ? { present: true, loadError: report.projectLoadError }
1876
+ : report.project
1877
+ ? {
1878
+ present: true,
1879
+ capabilities: report.project.capabilities,
1880
+ health: report.project.health,
1881
+ }
1882
+ : null,
1883
+ cloudflare: {
1884
+ state: report.cloudflare.state,
1885
+ missing: report.cloudflare.missing,
1886
+ tokenStatus: report.cloudflare.tokenStatus,
1887
+ credentialSplit: report.cloudflare.credentialSplit,
1888
+ // The resolved file, absolute here rather than tilde-abbreviated, on the same rule as every other
1889
+ // path in this payload: `--json` is read by agents and scripts, which need a path they can open.
1890
+ configPath: report.cloudflare.configPath ?? null,
1891
+ accountName: report.cloudflare.accountName ?? null,
1892
+ accountMismatch: report.cloudflare.accountMismatch ?? null,
1893
+ // Beside `configPath` because the two are only useful together: the path is the file this run
1894
+ // resolved, and this is whether the credentials actually came out of it (#218).
1895
+ credentialSource: report.cloudflare.credentialSource ?? null,
1896
+ detail: describeCloudflareAccess(report.cloudflare),
1897
+ },
1898
+ // `null` alongside a `null` project: one fact, one shape, both keys agreeing that there is no project
1899
+ // here to name. An agent reading this never sees a name verdict for a directory that has no config.
1900
+ projectName: report.projectName
1901
+ ? {
1902
+ state: report.projectName.state,
1903
+ project: report.projectName.project,
1904
+ misnamed: report.projectName.misnamed,
1905
+ detail: describeProjectName(report.projectName),
1906
+ }
1907
+ : null,
1908
+ // Same `null` discipline: no project, no Workers, no verdict. Each mismatch carries its own sentence
1909
+ // so an agent fixing it never has to reproduce the wording from the fields.
1910
+ workerNames: report.workerNames
1911
+ ? {
1912
+ state: report.workerNames.state,
1913
+ mismatches: report.workerNames.mismatches.map((mismatch) => ({
1914
+ ...mismatch,
1915
+ detail: describeWorkerName(mismatch),
1916
+ })),
1917
+ }
1918
+ : null,
1919
+ // Same `null` discipline once more, and each drift carries its own sentence — the remedy for an
1920
+ // orphan is not the remedy for a disagreement, and a consumer must not have to work out which.
1921
+ environments: report.environments
1922
+ ? {
1923
+ state: report.environments.state,
1924
+ declared: report.environments.declared,
1925
+ drift: report.environments.drift.map((drift) => ({
1926
+ ...drift,
1927
+ detail: describeEnvironmentDrift(drift, report.environments?.declared ?? []),
1928
+ })),
1929
+ }
1930
+ : null,
1931
+ // Same `null` discipline once more, and each drift carries its own sentence: the remedy for "no
1932
+ // origin at all" is not the remedy for "workers.dev is open beside your domain", and a consumer must
1933
+ // not have to work out which from a fault name.
1934
+ origins: report.origins
1935
+ ? {
1936
+ state: report.origins.state,
1937
+ drift: report.origins.drift.map((drift) => ({ ...drift, detail: describeOriginDrift(drift) })),
1938
+ }
1939
+ : null,
1940
+ // Same `null` discipline, and each drift carries both sides of the comparison as well as its own
1941
+ // sentence: a consumer fixing this needs to know what the stanza binds *and* what the declaration
1942
+ // says, and reconstructing either from a fault name is not something a payload should ask for.
1943
+ workflows: report.workflows
1944
+ ? {
1945
+ state: report.workflows.state,
1946
+ drift: report.workflows.drift.map((drift) => ({ ...drift, detail: describeWorkflowDrift(drift) })),
1947
+ }
1948
+ : null,
1949
+ // Same `null` discipline, and each entry carries its own sentence so an agent never has to
1950
+ // reproduce the wording from the fields. Never a fault, so nothing here is a `state`.
1951
+ extensions: report.extensions
1952
+ ? {
1953
+ extensions: report.extensions.extensions.map((entry) => ({ ...entry, detail: describeExtension(entry) })),
1954
+ }
1955
+ : null,
1956
+ // The path is absolute here, not tilde-abbreviated: `--json` is read by agents and scripts, which need
1957
+ // a path they can open, not one a human recognizes. The same `null` discipline as the two above.
1958
+ devPreferences: report.devPreferences
1959
+ ? { ...report.devPreferences, detail: describeDevPreferences(report.devPreferences) }
1960
+ : null,
1961
+ // Same `null` discipline. `detail` is nullable here rather than always a sentence: a registry that is
1962
+ // present with nothing stray beside it has no verdict to give, and inventing one ("ok") would put a
1963
+ // string in front of agents that means nothing they can act on.
1964
+ portsRegistry: report.portsRegistry
1965
+ ? { ...report.portsRegistry, detail: describePortsRegistry(report.portsRegistry) }
1966
+ : null,
1967
+ // Same `null` discipline, and each finding carries its own sentence so an agent fixing it never has to
1968
+ // reproduce the wording from the fields. `mode` is octal-formatted here for the same reason: `420` is
1969
+ // not a permission anybody recognizes.
1970
+ devSecretsFile: report.devSecretsFile === null ? null : { ...report.devSecretsFile },
1971
+ devSecrets: jsonDevSecrets(report.devSecrets),
1972
+ // Same `null` discipline once more, and each finding carries its own sentence: one command writes
1973
+ // every one of them, and an agent must not have to reconstruct which from a binding name.
1974
+ secretBindings: report.secretBindings
1975
+ ? {
1976
+ state: report.secretBindings.state,
1977
+ missing: report.secretBindings.missing,
1978
+ detail: describeSecretBindings(report.secretBindings),
1979
+ }
1980
+ : null,
1981
+ // Every finding carries its own sentence, and **every skip is a key rather than an absence**: a
1982
+ // script asking "do this project's settings work" has to be able to tell a clean pass from a check
1983
+ // that never ran, and only `account.state` and `unchecked` say which (#411).
1984
+ settings: report.settings
1985
+ ? {
1986
+ state: report.settings.state,
1987
+ account: report.settings.account,
1988
+ checked: report.settings.checked,
1989
+ findings: report.settings.findings.map((finding) => ({
1990
+ ...finding,
1991
+ detail: describeSettingsFinding(finding),
1992
+ })),
1993
+ unchecked: report.settings.unchecked,
1994
+ detail: describeSettingsAccount(report.settings.account),
1995
+ }
1996
+ : null,
1997
+ // Not a verdict a script gates on — the simulator is a choice — so `live` is the field and the
1998
+ // `detail` is the run's own sentence about why.
1999
+ localDelivery: report.localDelivery
2000
+ ? {
2001
+ live: report.localDelivery.live,
2002
+ capability: report.localDelivery.capability,
2003
+ detail: describeLocalDelivery(report.localDelivery),
2004
+ }
2005
+ : null,
2006
+ devVarsLocal: reported(report.devVarsLocal)
2007
+ ? { ...report.devVarsLocal, detail: describeDevVarsLocal(report.devVarsLocal as DevVarsLocalCheck) }
2008
+ : report.devVarsLocal,
2009
+ // Names only, never a value — the same discipline as its neighbor. The whole `root` classification
2010
+ // is carried rather than only the findings, because an agent asking "what is in that file and what
2011
+ // reads it" is asking the question the classification *is*, and a filtered list answers half of it.
2012
+ devVars: reported(report.devVars)
2013
+ ? { ...report.devVars, detail: describeDevVars(report.devVars as DevVarsCheck) }
2014
+ : report.devVars,
2015
+ os: `${report.os.name} ${report.os.version}`,
2016
+ runtime: report.runtime,
2017
+ node: report.node,
2018
+ };
2019
+ }
2020
+
2021
+ export default defineCommand({
2022
+ meta: { name: "doctor", description: "Check the toolchain, project, and for a new CLI version" },
2023
+ args: {
2024
+ worker: { type: "string", description: "Check only this worker (default: every worker under apps/)" },
2025
+ "disable-notifier": { type: "boolean", default: false, description: "Turn off the update notifier (persisted)" },
2026
+ "enable-notifier": { type: "boolean", default: false, description: "Turn the update notifier back on" },
2027
+ // No `default: false`. An unpassed flag has to stay absent so `PITHY_OFFLINE` can answer, and citty's
2028
+ // default would make every run an explicit "not offline" that overrode the variable.
2029
+ offline: { type: "boolean", description: "Use no ambient credentials and make no network call" },
2030
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
2031
+ },
2032
+ run: ({ args }) =>
2033
+ withErrorReporting(args.json, async () => {
2034
+ if (args["disable-notifier"] && args["enable-notifier"]) {
2035
+ throw new ValidationError({
2036
+ message: "Pass either --disable-notifier or --enable-notifier, not both.",
2037
+ action: "Choose one.",
2038
+ });
2039
+ }
2040
+ const file = stateFilePath();
2041
+ if (args["disable-notifier"]) await setNotifierFlag(file, false);
2042
+ if (args["enable-notifier"]) await setNotifierFlag(file, true);
2043
+
2044
+ const report = await buildDoctorReport({
2045
+ projectDir: process.cwd(),
2046
+ stateFile: file,
2047
+ ...(args.worker ? { worker: args.worker } : {}),
2048
+ ...(args.offline === undefined ? {} : { offline: args.offline }),
2049
+ });
2050
+ const output = args.json ? formatJsonLine(renderDoctorJson(report)) : renderDoctorText(report);
2051
+ process.stdout.write(`${output}\n`);
2052
+
2053
+ const code = doctorExitCode(report);
2054
+ if (code !== 0) process.exit(code);
2055
+ }),
2056
+ });
2057
+
2058
+ // Re-export the shared plan-builder reference so a test can assert the engine is shared with upgrade.
2059
+ export { buildReconcilePlan };