@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,252 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { stat } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { portsRegistryPath, readPortsRegistry, registryRootExists, registryRootFor } from "../feature/ports";
8
+ import type { StatePathOptions } from "../notifier/state";
9
+
10
+ /**
11
+ * One allocated block, flattened out of the registry's two levels of keys and told apart from its
12
+ * neighbors (#436).
13
+ *
14
+ * Flat rather than nested because the listing is a number line: the question it answers is *what holds
15
+ * 8827*, and a shape that has to be walked root-by-root to answer it is a shape the renderer would
16
+ * flatten anyway. Nothing is recorded here that the registry does not already hold — `own` and `onDisk`
17
+ * are the two facts about a row that the file cannot state about itself.
18
+ */
19
+ export interface PortsRegistryEntry {
20
+ /** The absolute main-checkout root that holds the block — the registry's outer key. */
21
+ root: string;
22
+ /** The branch the block is pinned to, e.g. `feature/12-auth`. */
23
+ branch: string;
24
+ /** The block index. Reported for `--json`; the text listing prints the range, which is the legible form. */
25
+ block: number;
26
+ /** The first port in the block. */
27
+ base: number;
28
+ /** How many ports the block spans. Registries written before `BLOCK_SIZE` changed carry mixed widths. */
29
+ size: number;
30
+ /**
31
+ * Whether this block belongs to the checkout doctor is being run in.
32
+ *
33
+ * The report's two halves: own blocks answer "which ports am I on", everything else answers "and who
34
+ * took the ones I am not on". `false` for every row when the root would not resolve — see
35
+ * {@link PortsRegistryCheck.root}.
36
+ */
37
+ own: boolean;
38
+ /**
39
+ * Whether the root is still on disk, on `registryRootExists`' rule — only a definite `ENOENT` is absence.
40
+ *
41
+ * `false` is the one line in this report a developer can act on, and the only place it can ever be
42
+ * said. Pruning cannot tell a deleted checkout from a moved one; it frees the blocks either way and
43
+ * nothing anywhere reports that it happened. This row is taken **before** the sweep.
44
+ */
45
+ onDisk: boolean;
46
+ }
47
+
48
+ /**
49
+ * Where this machine's dev-port registry is, what is in it, and whether an older CLI left one behind in
50
+ * the checkout.
51
+ *
52
+ * **Why a diagnostic owns this at all.** The registry moved out of the main repo root in #435, and being
53
+ * inside the checkout was the only thing that ever made it findable: it was git-ignored, but it was
54
+ * *there* — in the file tree the developer already had open, in the `ls` they already ran. In the config
55
+ * directory it is a file that decides every port `pithy dev` binds and that nothing in the project
56
+ * mentions. `pithy doctor` already reports `Config dir:` and the dev-secrets file for exactly this
57
+ * reason; this is the third of the same kind.
58
+ *
59
+ * **And why it reads the file rather than pointing at it (#436).** Naming the path answered *where*, and
60
+ * left *why is this project on 8847* with no answer short of `cat`. The registry already holds every fact
61
+ * the listing needs — it is `root → branch → PortBlock`, Zod-validated on read — so nothing new is
62
+ * recorded anywhere to support this. It is the report, from the one record.
63
+ *
64
+ * **A location, never a fault.** Nothing here fails the exit code. The absent state is the correct state
65
+ * on a machine that has not run `pithy feature create` yet, a stray file is untidy rather than broken,
66
+ * and a checkout that is gone from disk is information — a stale root failing CI would be absurd.
67
+ */
68
+ export interface PortsRegistryCheck {
69
+ /**
70
+ * The resolved absolute path, whether or not the file exists. Always set: telling a developer where the
71
+ * file *would* go is most of what this check is for. The text renderer abbreviates it against `$HOME`;
72
+ * `--json` carries it whole.
73
+ */
74
+ path: string;
75
+ /** Whether the registry is on disk yet. */
76
+ present: boolean;
77
+ /**
78
+ * A `.dev-ports.json` still sitting at this checkout's root, or `null`.
79
+ *
80
+ * Left by a CLI that predates #435. Nothing reads it, and nothing ever will — so it is neither a fault
81
+ * nor something the CLI should delete on someone's behalf. It is named so that the developer wondering
82
+ * why editing it changes no ports gets an answer, and so it gets deleted by the person who owns it.
83
+ *
84
+ * **Checked at `projectDir`, not at the main checkout root.** From a worktree those differ, and the
85
+ * stray file is at the main root, which is where doctor is usually run. Unchanged by #436's resolving
86
+ * that root: the resolution is allowed to fail, so a stray hunted through it would go unreported on the
87
+ * machines that have no git — and untidy-but-silent is the smaller failure.
88
+ */
89
+ stray: string | null;
90
+ /**
91
+ * This checkout's main repo root — the registry key its own blocks are filed under — or `null` when
92
+ * even the fallback would not answer.
93
+ *
94
+ * **`registryRootFor`, which is the key `pithy dev` allocates under, and never a second derivation of
95
+ * it.** `git rev-parse` is what makes a worktree's blocks show as its own — the worktree's directory is
96
+ * not the registry key, the main checkout's is — and where there is no repository the project's own
97
+ * canonical path is the key, because that is what `dev` files under there. Asking `rev-parse` alone
98
+ * reported a machine-with-no-git its own block as some other checkout's.
99
+ *
100
+ * `null` is unreachable through the default, which cannot reject; it is the state left for a
101
+ * {@link PortsRegistryOptions.resolveRoot} seam that throws. The listing still prints — every row is
102
+ * then somebody else's, which is more than the path-only line ever said.
103
+ */
104
+ root: string | null;
105
+ /**
106
+ * Why the registry could not be read and what to do about it, or `null`. `entries` is empty either way,
107
+ * and the difference is everything: an unreadable registry is one `pithy dev` refuses to allocate
108
+ * against at all, and a listing that showed it as empty would report the exact opposite of what is true.
109
+ *
110
+ * **The `action` is carried, not dropped.** It is the half naming the fix — *delete this file and
111
+ * re-run `pithy feature create`*, *it is a directory, not a file* — and the audience on both surfaces
112
+ * this reaches is the operator, whose field it is. The message alone told a developer their registry
113
+ * was corrupt and left them to work out the rest, while the same failure through `pithy dev` named it.
114
+ */
115
+ unreadable: string | null;
116
+ /**
117
+ * Every block in the registry, this checkout's first and each set in port order.
118
+ *
119
+ * Own blocks lead because *which ports do I hold* is the question asked most; the rest follow as a
120
+ * number line, which is what turns "8847, and I don't know why" into "because `other-app` holds
121
+ * 8827–8846". Empty when there is no file yet, and when there is one nothing could read.
122
+ */
123
+ entries: PortsRegistryEntry[];
124
+ }
125
+
126
+ /** Inputs to {@link checkPortsRegistry} — the config-directory resolution, plus the one seam a test needs. */
127
+ export interface PortsRegistryOptions extends StatePathOptions {
128
+ /**
129
+ * How the registry key for this project is resolved. Defaults to {@link registryRootFor} — the same
130
+ * function `pithy dev` allocates under, so the two cannot disagree about which blocks are this
131
+ * checkout's.
132
+ *
133
+ * A seam because the default spawns `git`, and a suite that had to build a repository to assert which
134
+ * rows are its own would be testing git.
135
+ */
136
+ resolveRoot?: (cwd: string) => Promise<string>;
137
+ }
138
+
139
+ /** Whether a path is a readable file. Never throws: an unreachable path is simply not a file we found. */
140
+ async function isFile(path: string): Promise<boolean> {
141
+ try {
142
+ return (await stat(path)).isFile();
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** The legacy location — the one every project carried before #435. */
149
+ const LEGACY_REGISTRY_FILE_NAME = ".dev-ports.json";
150
+
151
+ /**
152
+ * The checkout root, or `null` if it would not resolve.
153
+ *
154
+ * The whole reason this check can afford a `git` spawn it was written to avoid: the answer is which rows
155
+ * are yours, and no cheaper question produces it. Guarded, so the contract that this never throws is the
156
+ * catch rather than the absence of the call — the default cannot reach it, and an injected seam can.
157
+ */
158
+ async function ownRoot(projectDir: string, resolveRoot: (cwd: string) => Promise<string>): Promise<string | null> {
159
+ try {
160
+ return await resolveRoot(projectDir);
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * A failure as the operator needs it: what went wrong, then what to do. Both halves, because both are
168
+ * theirs — see {@link PortsRegistryCheck.unreadable}.
169
+ */
170
+ function operatorSentence(err: unknown): string {
171
+ if (err instanceof PithyError) {
172
+ const { message, action } = err.payload;
173
+ return action ? `${message} ${action}` : message;
174
+ }
175
+ return err instanceof Error ? err.message : String(err);
176
+ }
177
+
178
+ /**
179
+ * Read the registry, or say why not. One reader with the allocator (`readPortsRegistry`), so doctor can
180
+ * never list a row an allocation would refuse to touch.
181
+ */
182
+ async function readEntries(
183
+ registryPath: string,
184
+ root: string | null,
185
+ ): Promise<{ entries: PortsRegistryEntry[]; unreadable: string | null }> {
186
+ let registry: Awaited<ReturnType<typeof readPortsRegistry>>;
187
+ try {
188
+ registry = await readPortsRegistry(registryPath);
189
+ } catch (err) {
190
+ return { entries: [], unreadable: operatorSentence(err) };
191
+ }
192
+
193
+ const entries: PortsRegistryEntry[] = [];
194
+ for (const [entryRoot, branches] of Object.entries(registry)) {
195
+ const onDisk = await registryRootExists(entryRoot);
196
+ for (const [branch, block] of Object.entries(branches)) {
197
+ entries.push({ root: entryRoot, branch, ...block, own: entryRoot === root, onDisk });
198
+ }
199
+ }
200
+ // Own first, then a number line. `base` alone would interleave the two halves, and a registry holding
201
+ // mixed block widths (#435 left some at 10) has no index order that reads as one anyway.
202
+ entries.sort((a, b) => Number(b.own) - Number(a.own) || a.base - b.base || a.branch.localeCompare(b.branch));
203
+ return { entries, unreadable: null };
204
+ }
205
+
206
+ /**
207
+ * Resolve the registry path, read what is in it, and look for a stray legacy file beside the checkout.
208
+ *
209
+ * Never throws, on the rule every probe in this report follows: a diagnostic that can fail the command it
210
+ * is diagnosing is worse than one that says less.
211
+ */
212
+ export async function checkPortsRegistry(
213
+ projectDir: string,
214
+ options: PortsRegistryOptions = {},
215
+ ): Promise<PortsRegistryCheck> {
216
+ const path = portsRegistryPath(options);
217
+ const legacy = join(projectDir, LEGACY_REGISTRY_FILE_NAME);
218
+ const root = await ownRoot(projectDir, options.resolveRoot ?? registryRootFor);
219
+ const { entries, unreadable } = await readEntries(path, root);
220
+ return {
221
+ path,
222
+ present: await isFile(path),
223
+ stray: (await isFile(legacy)) ? legacy : null,
224
+ root,
225
+ unreadable,
226
+ entries,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The verdict half of the `Ports:` line, or `null` when the path alone says everything.
232
+ *
233
+ * Ordered by what a developer can do about it. An unreadable registry wins outright: every other state
234
+ * here is about a file that works, and this one is a file no allocation will get past — the listing under
235
+ * it is empty for that reason and not for the innocent one. The stray comes next, because "the file you
236
+ * can see is not the file in use" is the sentence that closes a gap nothing else can, and it is worth
237
+ * more than restating that the real registry is present.
238
+ */
239
+ export function describePortsRegistry(check: PortsRegistryCheck): string | null {
240
+ if (check.unreadable !== null) {
241
+ // Three short sentences rather than one nested in a parenthesis: the listing is missing, here is why,
242
+ // here is the fix. The last of those is the error's own `action`, which is the operator's field.
243
+ return `could not be read. ${check.unreadable}`;
244
+ }
245
+ if (check.stray !== null) {
246
+ return `${check.stray} is left over and nothing reads it — delete it`;
247
+ }
248
+ if (!check.present) {
249
+ return "no file yet; the first pithy dev or feature create writes it";
250
+ }
251
+ return null;
252
+ }