@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,615 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { execFile } from "node:child_process";
5
+ import { open, stat, unlink } from "node:fs/promises";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { fromZodError, InternalError } from "@pithy-sh/core/src/error/pithyError";
9
+ import { z } from "zod";
10
+ import { ensureOwnerOnlyDirFor } from "../devSecrets/mode";
11
+ import { type StatePathOptions, stateDir } from "../notifier/state";
12
+ import { writeFileAtomic } from "../project/atomic";
13
+ import { readOptionalFile } from "../project/readOptionalFile";
14
+ import { canonicalRepoPath } from "./worktree";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ /**
19
+ * The remedy for a file that is there and would not open, chosen from the errno (#217).
20
+ *
21
+ * `readOptionalFile`'s `unreadable` is **every errno but `ENOENT`**. *Check permissions on X* answers
22
+ * one of them, and a registry that is a directory, a symlink loop, or a failing disk got the same
23
+ * sentence. See `manifest.ts` for the same helper over the same decision, and for why two copies are
24
+ * under this repository's threshold for hoisting it.
25
+ */
26
+ function unreadableAction(code: string | undefined, name: string): string {
27
+ switch (code) {
28
+ case "EACCES":
29
+ case "EPERM":
30
+ return `Check permissions on ${name}.`;
31
+ case "EISDIR":
32
+ return `${name} is a directory, not a file. Remove it, then re-run.`;
33
+ case "ELOOP":
34
+ return `${name} is a symlink loop. Replace it with a regular file, then re-run.`;
35
+ default:
36
+ return `${name} is there and would not open (${code ?? "unknown error"}). Check that file, then re-run.`;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The remedy for a registry that will not parse. One function because it is one sentence, and it used to
42
+ * be written out at both throw sites — which is how two copies of a sentence drift.
43
+ *
44
+ * **It names the absolute path, not the file name.** The registry left the checkout in #435, so *delete
45
+ * `.dev-ports.json`* names a file no `ls` in the project finds and no editor's file tree reaches. Same
46
+ * argument `pithy doctor`'s `Secrets:` line is built on: knowing a file is broken is not the same as
47
+ * having a way to get at it.
48
+ */
49
+ function corruptAction(registryPath: string): string {
50
+ return `Delete ${registryPath} and re-run pithy feature create to rebuild it.`;
51
+ }
52
+
53
+ /** Read a property off an unknown throwable without widening anything to `any`. */
54
+ function prop(cause: unknown, key: string): unknown {
55
+ if (typeof cause !== "object" || cause === null) return undefined;
56
+ return (cause as Record<string, unknown>)[key];
57
+ }
58
+
59
+ /**
60
+ * The remedy for a failed `git rev-parse`, chosen from the failure (#217).
61
+ *
62
+ * *Run pithy from inside a git repository* is right for one of the three ways this can fail, and it is
63
+ * the one an adopter is least likely to be in — a machine without `git` on PATH fails to **spawn**, and
64
+ * the sentence tells them to go somewhere they already are. Duck-typed on purpose: `execFile`'s rejection
65
+ * is not required to be an `Error` subclass by anything the CLI controls, and its `code` is a string
66
+ * errno for a spawn failure and a number for a non-zero exit.
67
+ */
68
+ function gitResolveAction(cause: unknown): string {
69
+ const code = prop(cause, "code");
70
+ if (code === "ENOENT") return "Install git, or put it on PATH, then re-run.";
71
+ const stderr = prop(cause, "stderr");
72
+ const text = typeof stderr === "string" ? stderr : "";
73
+ if (/not a git repository|this operation must be run in a work tree/i.test(text)) {
74
+ return "Run pithy from inside a git repository.";
75
+ }
76
+ return "git rev-parse --git-common-dir failed. Run it here to see why, then re-run.";
77
+ }
78
+
79
+ /** First port of block 0. */
80
+ export const BASE_PORT = 8787;
81
+ /**
82
+ * Ports per feature block — one port per member of the dev set, so the width is a ceiling on what
83
+ * `pithy dev` can start.
84
+ *
85
+ * **Sized for `apps/*` plus every composed capability host** (pithy-sh/pithy#410). A host Worker is an
86
+ * ordinary member of the dev set and takes a pinned port like any other, and the kit ships eight of
87
+ * them. Ten covered a default two-Worker scaffold composing all of them *exactly*, so a third Worker
88
+ * turned `pithy dev` into a refusal to start anything. `ports.test.ts` pins the width against the host
89
+ * registry, so a ninth capability host fails there rather than in somebody's session.
90
+ */
91
+ export const BLOCK_SIZE = 20;
92
+
93
+ /** How many times `withLock` retries acquiring the lock before giving up. */
94
+ export const LOCK_MAX_ATTEMPTS = 50;
95
+ /** Delay between lock-acquire retries, in ms. */
96
+ export const LOCK_RETRY_DELAY_MS = 100;
97
+
98
+ /**
99
+ * How long a caller is willing to wait for the lock. Both values default to the two constants above,
100
+ * which are what every command uses; nothing in the CLI passes this.
101
+ *
102
+ * **It exists so a test can assert the rule rather than the clock** (#194). The one test that has to
103
+ * *exhaust* the budget — a fresh lock must not be reclaimed, so every retry must fail — cost
104
+ * `LOCK_MAX_ATTEMPTS × LOCK_RETRY_DELAY_MS`, which is 5000ms, which is vitest's default timeout to the
105
+ * millisecond. It passed on an idle machine and failed on a busy one, having proved nothing that three
106
+ * attempts at 10ms do not: the assertion is that a fresh lock survives a spent budget, and the size of
107
+ * the budget is not part of it. Two unrelated numbers matching is what made it marginal, and a test one
108
+ * scheduling hiccup from red now runs on every pull request (#173).
109
+ */
110
+ export interface LockBudget {
111
+ /** Attempts before giving up. Defaults to {@link LOCK_MAX_ATTEMPTS}. */
112
+ maxAttempts?: number;
113
+ /** Delay between attempts, in ms. Defaults to {@link LOCK_RETRY_DELAY_MS}. */
114
+ retryDelayMs?: number;
115
+ }
116
+ /**
117
+ * A lock file older than this is treated as abandoned, not held. A legitimate hold only ever spans one
118
+ * registry read-modify-write — a single small JSON file, no network calls — so it never approaches this.
119
+ * Anything this old can only be a lock a process left behind by dying mid-hold (Ctrl-C, SIGKILL), which
120
+ * used to brick the registry — and, worse, block `feature destroy` teardown — forever. 30s leaves a wide
121
+ * margin over real hold times while still recovering promptly.
122
+ */
123
+ export const LOCK_STALE_MS = 30_000;
124
+
125
+ /** A contiguous block of ports assigned to one feature branch. */
126
+ export const PortBlock = z
127
+ .object({
128
+ block: z.number().int().nonnegative().describe("The block index (0-based)."),
129
+ base: z.number().int().positive().describe("The first port in the block."),
130
+ size: z.number().int().positive().describe("How many ports the block spans."),
131
+ })
132
+ .describe("A contiguous block of ports assigned to one feature branch.");
133
+ export type PortBlock = z.output<typeof PortBlock>;
134
+
135
+ /** One checkout's allocations: branch name → its allocated block. */
136
+ export const RepoPortBlocks = z
137
+ .record(z.string().describe('A branch name, e.g. "feature/69-media-cli".'), PortBlock)
138
+ .describe("One main checkout's allocations: branch name → its allocated block.");
139
+ export type RepoPortBlocks = z.output<typeof RepoPortBlocks>;
140
+
141
+ /**
142
+ * The registry file shape: absolute main-checkout root → branch → its allocated block.
143
+ *
144
+ * **Keyed on the checkout, not the project name (#435).** The file is machine-wide now, so the key is
145
+ * the only partition left, and a project `name` is not one: `devSecrets/location.ts` already documents
146
+ * that two unrelated projects both called `app` collide on it, and here that collision would hand them
147
+ * the same ports — the exact defect this key exists to prevent. The root is also the key the pruner
148
+ * asks the filesystem about, which is what stops a machine-lifetime file from growing forever.
149
+ */
150
+ export const PortsRegistry = z
151
+ .record(z.string().describe("The absolute path of a main checkout root."), RepoPortBlocks)
152
+ .describe("The registry file shape: absolute main-checkout root → branch → its allocated block.");
153
+ export type PortsRegistry = z.output<typeof PortsRegistry>;
154
+
155
+ /** Inputs to `allocatePortBlock`. */
156
+ export interface AllocateOptions {
157
+ /** Absolute path to the registry — `<config>/dev-ports.json`, see {@link portsRegistryPath}. */
158
+ registryPath: string;
159
+ /** The absolute main-checkout root this branch belongs to — the registry's outer key. */
160
+ root: string;
161
+ /** The feature branch key, e.g. "feature/69-media-cli". */
162
+ branch: string;
163
+ /** Ports per block (default BLOCK_SIZE). */
164
+ size?: number;
165
+ /** How long to wait for the registry lock. Defaults to the production budget — see {@link LockBudget}. */
166
+ lock?: LockBudget;
167
+ }
168
+
169
+ /** Inputs to `freePortBlock`. */
170
+ export interface FreeOptions {
171
+ /** Absolute path to the registry — `<config>/dev-ports.json`, see {@link portsRegistryPath}. */
172
+ registryPath: string;
173
+ /** The absolute main-checkout root the branch belongs to — the registry's outer key. */
174
+ root: string;
175
+ /** The feature branch key to release. */
176
+ branch: string;
177
+ /** How long to wait for the registry lock. Defaults to the production budget — see {@link LockBudget}. */
178
+ lock?: LockBudget;
179
+ }
180
+
181
+ function sleep(ms: number): Promise<void> {
182
+ return new Promise((res) => setTimeout(res, ms));
183
+ }
184
+
185
+ /** Identifying content written into a freshly-acquired lock file, so a stuck one can be diagnosed by hand. */
186
+ function lockContents(): string {
187
+ return JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() });
188
+ }
189
+
190
+ /**
191
+ * If `lockPath` is older than `LOCK_STALE_MS`, remove it so the next `open(lockPath, "wx")` attempt can
192
+ * succeed. Staleness is judged from the file's own mtime, not its JSON content — the content write happens
193
+ * a moment after the atomic create, so trusting mtime avoids treating that narrow window as corrupt.
194
+ *
195
+ * Never assumes the removal wins: another process racing the same stale lock may reclaim or release it
196
+ * first, in which case this is a harmless no-op and the caller's next `open` attempt settles who gets it.
197
+ */
198
+ async function reclaimIfStale(lockPath: string): Promise<void> {
199
+ let mtimeMs: number;
200
+ try {
201
+ ({ mtimeMs } = await stat(lockPath));
202
+ } catch {
203
+ return; // Already gone — released or reclaimed by someone else. The next open() attempt will settle it.
204
+ }
205
+
206
+ if (Date.now() - mtimeMs < LOCK_STALE_MS) {
207
+ return; // Fresh enough that another process may genuinely be holding it.
208
+ }
209
+
210
+ await unlink(lockPath).catch(() => {}); // Tolerate losing the race to reclaim it.
211
+ }
212
+
213
+ /**
214
+ * Acquire an advisory lock on `${registryPath}.lock`, retrying until it succeeds or times out. A lock
215
+ * left behind by a process that died mid-hold is reclaimed once it goes stale (see `LOCK_STALE_MS`)
216
+ * instead of bricking the registry forever.
217
+ */
218
+ async function acquireLock(registryPath: string, budget: LockBudget = {}): Promise<string> {
219
+ const lockPath = `${registryPath}.lock`;
220
+ const maxAttempts = budget.maxAttempts ?? LOCK_MAX_ATTEMPTS;
221
+ const retryDelayMs = budget.retryDelayMs ?? LOCK_RETRY_DELAY_MS;
222
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
223
+ try {
224
+ const handle = await open(lockPath, "wx");
225
+ try {
226
+ await handle.writeFile(lockContents());
227
+ } finally {
228
+ await handle.close();
229
+ }
230
+ return lockPath;
231
+ } catch (err) {
232
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
233
+ await reclaimIfStale(lockPath);
234
+ }
235
+ await sleep(retryDelayMs);
236
+ }
237
+ }
238
+ // The budget that was actually spent, not the constant — a refusal naming 50 after 3 attempts is a
239
+ // refusal that sends the reader to the wrong number.
240
+ throw new InternalError({
241
+ message: "Could not lock the port registry.",
242
+ action: `If no other pithy process is running, delete ${lockPath} by hand and retry.`,
243
+ detail: `Timed out acquiring ${lockPath} after ${maxAttempts} attempts (stale threshold ${LOCK_STALE_MS}ms).`,
244
+ });
245
+ }
246
+
247
+ /** Release the advisory lock, swallowing any error (e.g. already removed). */
248
+ async function releaseLock(lockPath: string): Promise<void> {
249
+ await unlink(lockPath).catch(() => {});
250
+ }
251
+
252
+ /**
253
+ * Make sure the directory holding the registry exists, before anything tries to open a file in it.
254
+ *
255
+ * **The lock cannot report this failure (#435).** `acquireLock` opens `${registryPath}.lock` with `"wx"`
256
+ * and treats every errno that is not `EEXIST` the same way — sleep, retry — so a missing config directory
257
+ * would spend the whole 50 × 100ms budget and then refuse with *delete the lock file by hand*, naming a
258
+ * file that was never created inside a directory that does not exist. Loudly wrong about the wrong thing,
259
+ * and it reads as a lock bug forever. The registry left the checkout, so its directory is no longer one
260
+ * some earlier command already made — and on a fresh machine this can be the first thing to create it.
261
+ *
262
+ * **Which is why it goes through {@link ensureOwnerOnlyDirFor} and not a bare `mkdir`.** `<config>` itself
263
+ * is `0700`: `cloudflare.json` sits directly in it, and the writer that mints one narrows this exact
264
+ * directory on every write. A private `mkdir` here would be the fourth writer under this root, and
265
+ * `mode.ts` says in as many words what the fourth writer does — *a private copy per writer is how the
266
+ * third one lands at the umask default*. First command on a new machine being `pithy dev` would leave
267
+ * `~/.config/pithy` at `0755` for as long as nobody happened to write a credential.
268
+ *
269
+ * A failure is a `PithyError` like every other refusal in this module. The raw errno out of `mkdir` would
270
+ * surface through `allocatePortBlock` with no action line, from a path the operator cannot see.
271
+ */
272
+ async function ensureRegistryDir(registryPath: string): Promise<void> {
273
+ try {
274
+ await ensureOwnerOnlyDirFor(registryPath);
275
+ } catch (err) {
276
+ const code = (err as NodeJS.ErrnoException).code;
277
+ throw new InternalError({
278
+ message: "Could not open the Pithy config directory.",
279
+ action: `${unreadableAction(code, dirname(registryPath))} It holds the dev port registry; PITHY_CONFIG_DIR moves it.`,
280
+ detail: `${code ?? "unknown error"}: ${err instanceof Error ? err.message : String(err)}`,
281
+ });
282
+ }
283
+ }
284
+
285
+ /** Run `fn` while holding the advisory lock on `registryPath`, always releasing it after. */
286
+ async function withLock<T>(registryPath: string, fn: () => Promise<T>, budget?: LockBudget): Promise<T> {
287
+ await ensureRegistryDir(registryPath);
288
+ const lockPath = await acquireLock(registryPath, budget);
289
+ try {
290
+ return await fn();
291
+ } finally {
292
+ await releaseLock(lockPath);
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Read the registry file. A **missing** file is an empty registry; invalid JSON/shape throws
298
+ * `InternalError`, and so does one that is there and will not open.
299
+ *
300
+ * That distinction is {@link readOptionalFile}'s. It matters here as much as anywhere: every writer
301
+ * below is a read-modify-write, so a registry read as empty is a registry rewritten holding only this
302
+ * branch — and every other feature's block handed straight back out.
303
+ *
304
+ * **Exported for `pithy doctor`'s listing (#436), which reads it and never writes.** One reader, because
305
+ * the alternative is a second, laxer parse of the same file: doctor would then print rows an allocation
306
+ * refuses to touch, or an empty listing for a registry that is simply corrupt — and "nothing holds any
307
+ * ports" is the opposite of what a corrupt registry means. The throw is the report's material, not a
308
+ * problem for it; the caller catches it and says so on the line.
309
+ */
310
+ export async function readPortsRegistry(registryPath: string): Promise<PortsRegistry> {
311
+ const raw = await readOptionalFile(registryPath, {
312
+ unreadable: ({ code, cause }) =>
313
+ new InternalError({
314
+ message: "Could not read the port registry.",
315
+ action: unreadableAction(code, registryPath),
316
+ detail: `${code ?? "unknown error"}: ${cause instanceof Error ? cause.message : String(cause)}`,
317
+ }),
318
+ });
319
+ if (raw === null) return {};
320
+
321
+ let parsed: unknown;
322
+ try {
323
+ parsed = JSON.parse(raw);
324
+ } catch (err) {
325
+ throw new InternalError({
326
+ message: "The port registry is corrupt.",
327
+ action: corruptAction(registryPath),
328
+ detail: err instanceof Error ? err.message : String(err),
329
+ });
330
+ }
331
+
332
+ const result = PortsRegistry.safeParse(parsed);
333
+ if (!result.success) {
334
+ throw fromZodError(result.error, {
335
+ message: "The port registry is corrupt.",
336
+ action: corruptAction(registryPath),
337
+ });
338
+ }
339
+
340
+ return result.data;
341
+ }
342
+
343
+ /**
344
+ * Whether a checkout root is still on disk.
345
+ *
346
+ * **Only a definite `ENOENT` is absence.** Every other errno is the process failing to *reach* the path
347
+ * rather than the path being gone — a repo under a mount that is not up, a `PITHY_CONFIG_DIR` on a
348
+ * network share, a parent that stopped being a directory. Treating those as gone deletes a live
349
+ * checkout's whole allocation set in one atomic write, and the reclaim scan that could rebuild it cannot
350
+ * run for a root this process could not stat either.
351
+ *
352
+ * **Exported so `pithy doctor`'s listing marks the same roots this would sweep (#436).** The report's one
353
+ * actionable line is *this checkout is gone and its blocks are about to be freed*, and a second answer to
354
+ * "is it there" would let doctor promise a sweep the pruner does not make, or stay silent about one it does.
355
+ */
356
+ export async function registryRootExists(root: string): Promise<boolean> {
357
+ try {
358
+ await stat(root);
359
+ return true;
360
+ } catch (err) {
361
+ return (err as NodeJS.ErrnoException).code !== "ENOENT";
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Drop every checkout that is gone from disk, and every one left holding no branches. Returns whether
367
+ * anything changed, so the caller knows whether a write is owed.
368
+ *
369
+ * **This is what pays for the file being machine-wide (#435).** At the main repo root the registry died
370
+ * with the checkout, so `rm -rf` freed a project's ports for nothing. In the config directory nothing
371
+ * ever frees them, and a file that only grows is a file whose block indices only climb.
372
+ *
373
+ * `keep` is never pruned. The root being allocated for is the caller's own answer to "where am I", and a
374
+ * seam may legitimately hand over one that does not exist on disk; refusing to prune it costs nothing and
375
+ * removes a way for this function to delete the allocation it was called to make.
376
+ *
377
+ * **It cannot tell a deleted checkout from a moved one, and does not try.** Both are `ENOENT`, and the
378
+ * only thing that could separate them is a record of where a repository used to be — which is a second
379
+ * source of truth about identity, kept in the file whose whole problem was that it outlives what it
380
+ * describes. A moved repository's blocks are freed, and the next `pithy dev` in it re-registers the block
381
+ * its `.dev.config.json` still pins — see `ensureDevConfig`, which had to start doing that on the pinned
382
+ * path before this sentence was true of the command anybody runs. The window in between is one where
383
+ * another project can be handed one, and `pithy dev`'s dual-stack port check turns that into a reported
384
+ * conflict rather than two Workers on one port.
385
+ */
386
+ async function pruneDeadRoots(registry: PortsRegistry, keep: string): Promise<boolean> {
387
+ let changed = false;
388
+ for (const root of Object.keys(registry)) {
389
+ if (root === keep) continue;
390
+ const branches = registry[root];
391
+ if (Object.keys(branches ?? {}).length === 0 || !(await registryRootExists(root))) {
392
+ delete registry[root];
393
+ changed = true;
394
+ }
395
+ }
396
+ return changed;
397
+ }
398
+
399
+ /** Write the registry file atomically as pretty-printed JSON with a trailing newline. */
400
+ async function writeRegistry(registryPath: string, registry: PortsRegistry): Promise<void> {
401
+ await writeFileAtomic(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
402
+ }
403
+
404
+ /**
405
+ * The lowest block index that is neither taken nor overlapping a block already in the registry.
406
+ *
407
+ * The index alone is not enough, and that is the whole reason this takes the ranges. A block's ports
408
+ * are `BASE_PORT + index × size`, so two allocations of the *same* width can never overlap and the
409
+ * index is the whole answer — but a registry written before {@link BLOCK_SIZE} changed keeps its own
410
+ * entries verbatim, and a wide block 2 lands straight through a narrow block 4's ports. Two features
411
+ * would then bind one port, which is the collision this whole registry exists to prevent.
412
+ */
413
+ function lowestFreeBlock(taken: ReadonlySet<number>, size: number, held: readonly PortBlock[]): number {
414
+ const clashes = (base: number): boolean =>
415
+ held.some((block) => base < block.base + block.size && base + size > block.base);
416
+ let i = 0;
417
+ while (taken.has(i) || clashes(BASE_PORT + i * size)) {
418
+ i++;
419
+ }
420
+ return i;
421
+ }
422
+
423
+ /**
424
+ * Allocate (or return the existing) port block for a branch, under a file lock. Idempotent: if the
425
+ * branch already has a block, returns it unchanged. Otherwise assigns the LOWEST free block index not
426
+ * overlapping any taken block, writes the registry atomically, and returns it.
427
+ */
428
+ export async function allocatePortBlock(options: AllocateOptions): Promise<PortBlock> {
429
+ const { registryPath, root, branch } = options;
430
+ const size = options.size ?? BLOCK_SIZE;
431
+
432
+ return withLock(
433
+ registryPath,
434
+ async () => {
435
+ const registry = await readPortsRegistry(registryPath);
436
+ const pruned = await pruneDeadRoots(registry, root);
437
+
438
+ const existing = registry[root]?.[branch];
439
+ if (existing) {
440
+ // Deliberately still a write when something was pruned. Pruning is the *only* thing that frees a
441
+ // deleted project's ports, and a machine whose whole steady state is `pithy dev` on branches it
442
+ // already allocated never reaches the path below — so pruning only there would let the file grow
443
+ // for the life of the machine while block indices climbed past every dead checkout.
444
+ if (pruned) await writeRegistry(registryPath, registry);
445
+ return existing;
446
+ }
447
+
448
+ // Every block in every checkout, because the file is machine-wide now: a taken-set built from one
449
+ // root's blocks would put every project back at block 0, which is #435 reintroduced one flatMap up.
450
+ const held = Object.values(registry).flatMap((branches) => Object.values(branches));
451
+ const taken = new Set(held.map((entry) => entry.block));
452
+ const block = lowestFreeBlock(taken, size, held);
453
+ const allocated: PortBlock = { block, base: BASE_PORT + block * size, size };
454
+
455
+ const branches = registry[root] ?? {};
456
+ branches[branch] = allocated;
457
+ registry[root] = branches;
458
+ await writeRegistry(registryPath, registry);
459
+
460
+ return allocated;
461
+ },
462
+ options.lock,
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Re-register blocks that a worktree already holds but the registry has lost, under the lock.
468
+ *
469
+ * **What it guards narrowed in #435, and it did not go away.** At the main repo root the registry was
470
+ * git-ignored, so a fresh clone or a stray `git clean` took it while the worktrees allocated from it
471
+ * lived on with their ports pinned in `.dev.config.json`. In the config directory no checkout operation
472
+ * can reach it — only a wiped config directory, a new machine, or a `PITHY_CONFIG_DIR` pointed somewhere
473
+ * else. Rarer, and identical in consequence: without this the next allocation restarts at block 0 and
474
+ * hands out a block a live feature is already using.
475
+ *
476
+ * Reservations belong to **one** checkout, named by `root` — a worktree scan can only speak for the
477
+ * repository it walked, and writing its branches under anyone else's key is how one project's recovery
478
+ * would corrupt another's allocations.
479
+ *
480
+ * Only fills gaps: a branch already in the registry is left exactly as it is, so this never overwrites a
481
+ * live allocation. Returns the branches it re-registered.
482
+ */
483
+ export async function reclaimPortBlocks(options: {
484
+ /** The central registry path — `<config>/dev-ports.json`, see {@link portsRegistryPath}. */
485
+ registryPath: string;
486
+ /** The absolute main-checkout root the reservations were scanned from. */
487
+ root: string;
488
+ /** Blocks observed on disk, one per existing worktree. */
489
+ reservations: { branch: string; block: PortBlock }[];
490
+ /** How long to wait for the registry lock. Defaults to the production budget — see {@link LockBudget}. */
491
+ lock?: LockBudget;
492
+ }): Promise<string[]> {
493
+ if (options.reservations.length === 0) return [];
494
+
495
+ return withLock(
496
+ options.registryPath,
497
+ async () => {
498
+ const registry = await readPortsRegistry(options.registryPath);
499
+ const branches = registry[options.root] ?? {};
500
+ registry[options.root] = branches;
501
+ const reclaimed: string[] = [];
502
+ for (const { branch, block } of options.reservations) {
503
+ if (branch in branches) continue; // a live allocation always wins.
504
+ branches[branch] = block;
505
+ reclaimed.push(branch);
506
+ }
507
+ if (reclaimed.length > 0) await writeRegistry(options.registryPath, registry);
508
+ return reclaimed;
509
+ },
510
+ options.lock,
511
+ );
512
+ }
513
+
514
+ /** Free a branch's block under the lock (idempotent: a missing branch/checkout/registry is a no-op). */
515
+ export async function freePortBlock(options: FreeOptions): Promise<void> {
516
+ const { registryPath, root, branch } = options;
517
+
518
+ await withLock(
519
+ registryPath,
520
+ async () => {
521
+ const registry = await readPortsRegistry(registryPath);
522
+ const branches = registry[root];
523
+ if (branches === undefined || !(branch in branches)) {
524
+ return;
525
+ }
526
+ delete branches[branch];
527
+ // A checkout holding nothing is not a checkout the registry has anything to say about, and leaving
528
+ // the empty object behind would keep a deleted project in the file until the pruner reached it.
529
+ if (Object.keys(branches).length === 0) delete registry[root];
530
+ await writeRegistry(registryPath, registry);
531
+ },
532
+ options.lock,
533
+ );
534
+ }
535
+
536
+ /**
537
+ * `<config>/dev-ports.json` — one port space per machine, spanning every checkout on it (#435).
538
+ *
539
+ * **At the config root, not `<config>/<project>/`.** A per-project directory is the partition this file
540
+ * moved to get rid of: the registry sat at each main repo root, so every project kept its own, every one
541
+ * started empty, and every one handed out block 0 — two projects on their default branch bound the same
542
+ * twenty ports before either had done anything unusual. Nesting it under a project name would put the
543
+ * same defect back one directory down.
544
+ *
545
+ * The resolution is {@link stateDir}'s, unchanged and unrepeated — `$PITHY_CONFIG_DIR`, then
546
+ * `%APPDATA%\pithy`, then `$XDG_CONFIG_HOME/pithy`, then `~/.config/pithy`. Two implementations of "where
547
+ * does config live" is the defect shape `devSecrets/location.ts` names, and the Windows branch is the
548
+ * half a second one forgets.
549
+ */
550
+ export function portsRegistryPath(options: StatePathOptions = {}): string {
551
+ // The name is inline, not a constant. `state.test.ts` decides from the text whether a segment joined
552
+ // onto `stateDir()` is safe, and the only two answers it takes are a literal this repository typed and
553
+ // a validator's return — a `const` reads as neither. Undotted, on the `state.json` rule: nothing here
554
+ // is hidden from anything.
555
+ return join(stateDir(options), "dev-ports.json");
556
+ }
557
+
558
+ /**
559
+ * The main checkout's root, from any cwd inside the repository — the main checkout itself or any of its
560
+ * worktrees. This is the registry's outer key.
561
+ *
562
+ * It used to be welded to the registry's location, because they were the same answer: the file sat at
563
+ * this path. They are different questions now, and a single function that answered both would be a
564
+ * second derivation of a location that has exactly one (#435).
565
+ */
566
+ export async function resolveMainRepoRoot(cwd: string): Promise<string> {
567
+ let stdout: string;
568
+ try {
569
+ ({ stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd }));
570
+ } catch (err) {
571
+ // Three failures reach here and one sentence used to answer all of them (#217). `git` absent from
572
+ // PATH is a spawn `ENOENT` and no amount of standing in a repository fixes it; a non-repository is
573
+ // the exit-128 case the sentence was written for; anything else gets no remedy, only git's own words.
574
+ // Duck-typed: `code` is the string errno on a spawn failure and the numeric exit status otherwise.
575
+ throw new InternalError({
576
+ message: "Could not resolve the repo's git directory.",
577
+ action: gitResolveAction(err),
578
+ detail: err instanceof Error ? err.message : String(err),
579
+ });
580
+ }
581
+
582
+ const commonDir = stdout.trim();
583
+ const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve(cwd, commonDir);
584
+ const root = dirname(absoluteCommonDir);
585
+
586
+ // **Canonical, because the root is a key now and not a place to put a file** (#435). The same helper
587
+ // `mainRepoRoot` uses, and it has to be both of them: canonicalising one side alone fixes POSIX and
588
+ // breaks Windows, where git's forward slashes matched `dirname`'s output and stop matching
589
+ // `realpath`'s. See {@link canonicalRepoPath} for both divergences and why neither shows up in CI.
590
+ return canonicalRepoPath(root);
591
+ }
592
+
593
+ /**
594
+ * The registry key for a project directory, repository or not — {@link resolveMainRepoRoot}, and the
595
+ * project's own canonical path when there is no repository to ask.
596
+ *
597
+ * **One function because two callers must produce the same key or the registry lies** (#436). `pithy dev`
598
+ * allocated through this fallback while `pithy doctor` had its own, narrower answer, so on a machine with
599
+ * no `git` — or in a project that is not a repository — `dev` filed a block under the project directory
600
+ * and `doctor` reported that very block as some other checkout's, `own: false` and all. Every caller that
601
+ * wants "which key are my blocks under" asks here; only a caller that must *refuse* without a repository
602
+ * (`feature destroy`) reaches past it to `resolveMainRepoRoot`.
603
+ *
604
+ * Never rejects: {@link canonicalRepoPath} answers the uncanonicalised path rather than throwing, so the
605
+ * fallback always produces a key.
606
+ */
607
+ export async function registryRootFor(projectDir: string): Promise<string> {
608
+ try {
609
+ return await resolveMainRepoRoot(projectDir);
610
+ } catch {
611
+ // Canonical for the same reason `resolveMainRepoRoot` is: the answer is a registry key, and a project
612
+ // reached once through a symlink and once through the real path would occupy two of them.
613
+ return canonicalRepoPath(projectDir);
614
+ }
615
+ }