@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,1014 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { execFile, spawn as spawnChild } from "node:child_process";
5
+ import { createWriteStream, mkdirSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { isContinuousIntegration } from "@pithy-sh/core/src/env/ci";
9
+ import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
10
+ import type { DevLogin } from "@pithy-sh/core/src/seed/devLogin";
11
+ import { findEntitlementGap } from "../capabilities/entitlementGap";
12
+ import { type GenerateDevVarsResult, generateDevVars } from "../devSecrets/generate";
13
+ import { renderDevSecretsNotes, renderDevVarsNotes } from "../devSecrets/report";
14
+ import { type DevSecretsSeedReport, seedProjectDevSecrets } from "../devSecrets/seed";
15
+ import { localDevStateRoot } from "../devSecrets/store";
16
+ import {
17
+ buildDevConfig,
18
+ type DevConfig,
19
+ devConfigPath,
20
+ readDevConfig,
21
+ scanPinnedBlocks,
22
+ writeDevConfig,
23
+ } from "../feature/devConfig";
24
+ import {
25
+ allocatePortBlock,
26
+ type PortBlock,
27
+ portsRegistryPath,
28
+ reclaimPortBlocks,
29
+ registryRootFor,
30
+ } from "../feature/ports";
31
+ import { allCapabilities, loadProject, loadWorkerConfig, requireProjectName } from "../project/config";
32
+ import { detectPackageManager, execArgs } from "../project/packageManager";
33
+ import { defaultWorkerDev } from "../project/workerManifest";
34
+ import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "../project/workers";
35
+ import { formatJsonLine } from "../terminal/output";
36
+ import { dim, workerColor } from "../terminal/style";
37
+ import { hasCloudflareLogin as defaultHasCloudflareLogin, deliveryFailureNote, deliveryPreflight } from "./delivery";
38
+ import { type DevLoginTarget, devLoginKeyAction, devLoginLines, readDevLogin as readDevLoginDefault } from "./devLogin";
39
+ import { devLoginTargets as devLoginTargetsDefault } from "./devLoginTargets";
40
+ import { buildWorkerEnv, childEnvFor, ownOriginFor, startCommand, type WranglerLauncher } from "./env";
41
+ import {
42
+ discoverHostWorkers as discoverHostWorkersDefault,
43
+ type HostMaterialization,
44
+ type HostWorker,
45
+ type HostWorkerDiscovery,
46
+ hostDeliveryIdentity,
47
+ type MaterializeHostConfigsOptions,
48
+ materializeHostConfigs as materializeHostConfigsDefault,
49
+ } from "./hostWorkers";
50
+ import { type KeyReader, readKeys as readKeysDefault } from "./keys";
51
+ import { type DataStream, stripAnsi, teeStream } from "./logging";
52
+ import { openUrl as openUrlDefault } from "./openUrl";
53
+ import {
54
+ isAlive as isAliveDefault,
55
+ type Sleep,
56
+ sweepStaleDevPorts,
57
+ type TryBind,
58
+ tryBind as tryBindDefault,
59
+ verifyPinnedPort,
60
+ } from "./ports";
61
+ import { type ReadyWatch, type Schedule, stillWaitingLines, watchReady } from "./readyWatch";
62
+ import { type DevState, devStatePath, readDevState, removeDevState, writeDevState } from "./state";
63
+
64
+ /** A spawned child, minimally what the orchestrator drives — satisfied by a real `ChildProcess` or a fake. */
65
+ export interface ChildLike {
66
+ pid?: number;
67
+ stdout: DataStream | null;
68
+ stderr: DataStream | null;
69
+ once(event: "exit", listener: (code: number | null) => void): unknown;
70
+ /** The spawn error channel (e.g. ENOENT when a `dev.command` binary is missing) — required so it is handled, not thrown. */
71
+ once(event: "error", listener: (error: Error) => void): unknown;
72
+ }
73
+
74
+ /** The spawn seam. Detached makes the child a process-group leader, so `kill(-pid)` tears down its subtree. */
75
+ export type SpawnDev = (
76
+ command: string,
77
+ args: string[],
78
+ options: { cwd: string; env: Record<string, string>; detached: boolean },
79
+ ) => ChildLike;
80
+
81
+ /** A log destination — the terminal's tee'd copy in `logs/dev.log`, injectable so tests capture lines. */
82
+ export interface LogSink {
83
+ write: (line: string) => void;
84
+ end: () => Promise<void> | void;
85
+ }
86
+
87
+ /** Everything `startDev` needs, every dependency defaulted to its real implementation. */
88
+ /**
89
+ * One Worker's entitlement composition gap: the gating source files, or empty when there is none. A
90
+ * config that cannot be loaded yields no gap — `pithy dev` reports wiring, and a config that will not
91
+ * load is wrangler's error to raise, not a reason to invent an entitlement warning.
92
+ */
93
+ const defaultCheckEntitlements = async (workerDir: string): Promise<string[]> => {
94
+ try {
95
+ return await findEntitlementGap(workerDir, allCapabilities(await loadWorkerConfig(workerDir)));
96
+ } catch {
97
+ return [];
98
+ }
99
+ };
100
+
101
+ /**
102
+ * The project name every host's derived names lead with, or `null` when the project states none.
103
+ *
104
+ * `requireProjectName` rather than `resolveProjectName`: a guessed name differs between checkouts,
105
+ * and this one is stamped into a Worker script name. A project that states none gets no hosts and
106
+ * one line saying why — the alternative is a host running under a name nothing else in the project
107
+ * would reproduce.
108
+ */
109
+ const defaultProjectName = async (projectDir: string): Promise<string | null> => {
110
+ try {
111
+ return requireProjectName(await loadProject(projectDir));
112
+ } catch {
113
+ return null;
114
+ }
115
+ };
116
+
117
+ export interface StartDevOptions {
118
+ projectDir: string;
119
+ json?: boolean;
120
+ /** Test seam: the entitlement composition check, without loading a real `pithy.config.ts`. */
121
+ checkEntitlements?: (workerDir: string) => Promise<string[]>;
122
+ /** Seam: seed the dev secrets file into the local `SECRETS` store before anything spawns. */
123
+ seedSecrets?: (projectDir: string) => Promise<DevSecretsSeedReport>;
124
+ /** Seam: generate each Worker's `.dev.vars` before anything reads one. */
125
+ generateDevVars?: (projectDir: string, workerDirs: string[]) => Promise<GenerateDevVarsResult>;
126
+ discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
127
+ /** Seam: the host Worker of every capability the project's Workers compose. */
128
+ discoverHostWorkers?: (options: {
129
+ projectDir: string;
130
+ workers: readonly WorkerTarget[];
131
+ }) => Promise<HostWorkerDiscovery>;
132
+ /** Seam: resolve and write each host's local `wrangler.jsonc`. */
133
+ materializeHostConfigs?: (options: MaterializeHostConfigsOptions) => Promise<HostMaterialization>;
134
+ /** Seam: the project name every host's derived names lead with. `null` skips the hosts, loudly. */
135
+ projectName?: (projectDir: string) => Promise<string | null>;
136
+ /** Seam: whether Cloudflare credentials resolve at all — the cheap half of the delivery preflight. */
137
+ hasCloudflareLogin?: (projectDir: string, env: NodeJS.ProcessEnv) => Promise<boolean>;
138
+ loadDevConfig?: (projectDir: string) => Promise<DevConfig | null>;
139
+ /** Bootstrap seam: assign and persist pinned ports when the project has none yet. */
140
+ ensureDevConfig?: (options: EnsureDevConfigOptions) => Promise<DevConfig>;
141
+ /** Seams handed to the real {@link ensureDevConfig} (git branch, registry path, write). */
142
+ ensureDeps?: EnsureDevConfigDeps;
143
+ tryBind?: TryBind;
144
+ /** Reap our own orphans on the pinned ports. `knownPids` are the previous session's recorded children. */
145
+ sweep?: (ports: number[], knownPids: readonly number[]) => Promise<number[]>;
146
+ spawn?: SpawnDev;
147
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
148
+ isAlive?: (pid: number) => boolean;
149
+ sleep?: Sleep;
150
+ /** Seam: the ready-deadline timer. Real `setTimeout` in production, a hand-driven clock in tests. */
151
+ schedule?: Schedule;
152
+ launchWrangler?: WranglerLauncher;
153
+ hasSetsid?: boolean;
154
+ stdout?: (text: string) => void;
155
+ /** Seam: where the prose goes when stdout is reserved for JSON (`--json`). */
156
+ stderr?: (text: string) => void;
157
+ /** Seam: the seeded dev login the ready banner offers, if `pithy seed` wrote one. */
158
+ readDevLogin?: (projectDir: string) => Promise<DevLogin | undefined>;
159
+ /** Seam: which started workers carry the dev-login route (they compose auth). */
160
+ devLoginTargets?: (started: readonly { name: string; dir: string; origin: string }[]) => Promise<DevLoginTarget[]>;
161
+ /** Seam: the raw-mode key reader. Answers `active: false` on every non-TTY, and is never entered there. */
162
+ readKeys?: typeof readKeysDefault;
163
+ /** Seam: hand a URL to the platform's browser opener. */
164
+ openUrl?: (url: string) => Promise<void>;
165
+ openLog?: (path: string) => LogSink;
166
+ baseEnv?: NodeJS.ProcessEnv;
167
+ now?: () => Date;
168
+ readState?: (path: string) => Promise<DevState | null>;
169
+ writeState?: (path: string, state: DevState) => Promise<void>;
170
+ removeState?: (path: string, ownPid: number) => void;
171
+ ownPid?: number;
172
+ }
173
+
174
+ /** The resolved endpoint for one started worker. */
175
+ export interface StartedWorker {
176
+ name: string;
177
+ port: number;
178
+ origin: string;
179
+ }
180
+
181
+ /** A running dev session's handle — its resolved workers, its lifecycle promises, and a shutdown hook. */
182
+ export interface DevHandle {
183
+ workers: StartedWorker[];
184
+ /** Resolves once every started worker has matched its ready signal (the ready banner fires). */
185
+ ready: Promise<void>;
186
+ /** Resolves once the session has fully torn down (all children gone, state removed). */
187
+ closed: Promise<void>;
188
+ /** Tear the session down: SIGTERM every child group, SIGKILL survivors after a grace window, clean up. */
189
+ shutdown: (reason: string) => Promise<void>;
190
+ state: DevState;
191
+ }
192
+
193
+ /** How long a child gets to exit on SIGTERM before it is SIGKILLed. */
194
+ const SHUTDOWN_GRACE_MS = 5000;
195
+
196
+ const realSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
197
+
198
+ /** The real log sink: truncate `logs/dev.log` fresh, stream lines to it, flush on close. */
199
+ function openLogDefault(path: string): LogSink {
200
+ mkdirSync(dirname(path), { recursive: true });
201
+ const stream = createWriteStream(path, { flags: "w" });
202
+ return {
203
+ write: (line) => void stream.write(`${line}\n`),
204
+ end: () => new Promise<void>((resolve) => stream.end(() => resolve())),
205
+ };
206
+ }
207
+
208
+ /** The real spawn: a group-leader child (POSIX `setsid` via `detached`) with piped stdout/stderr. */
209
+ const spawnDefault: SpawnDev = (command, args, options) =>
210
+ spawnChild(command, args, {
211
+ cwd: options.cwd,
212
+ env: options.env,
213
+ detached: options.detached,
214
+ stdio: ["ignore", "pipe", "pipe"],
215
+ });
216
+
217
+ const execFileAsync = promisify(execFile);
218
+
219
+ /** Seams for {@link ensureDevConfig} — the git and registry lookups a test drives itself. */
220
+ export interface EnsureDevConfigDeps {
221
+ /** Resolve the machine's registry file (default: `<config>/dev-ports.json`). */
222
+ registryPathFor?: (projectDir: string) => Promise<string>;
223
+ /** The main checkout root, the registry's outer key (default: git-common-dir; the project itself with no repo). */
224
+ rootFor?: (projectDir: string) => Promise<string>;
225
+ /** The current branch, the registry's inner key (default: `git rev-parse --abbrev-ref HEAD`; `null` off a branch). */
226
+ branchFor?: (projectDir: string) => Promise<string | null>;
227
+ /** Persist the built config (default: {@link writeDevConfig}). */
228
+ writeConfig?: (path: string, config: DevConfig) => Promise<void>;
229
+ }
230
+
231
+ /** Arguments to {@link ensureDevConfig}. */
232
+ export interface EnsureDevConfigOptions extends EnsureDevConfigDeps {
233
+ /** The project (or worktree) root that owns `.dev.config.json`. */
234
+ projectDir: string;
235
+ /** Every discovered worker — not just the autostart set, so a port survives an autostart flip. */
236
+ workers: WorkerTarget[];
237
+ /** The config already on disk, whose worker→port pairs are preserved. `null` on first run. */
238
+ existing?: DevConfig | null;
239
+ }
240
+
241
+ /** The registry is machine-wide and always resolvable — no repository is involved in finding it (#435). */
242
+ async function defaultRegistryPath(_projectDir: string): Promise<string> {
243
+ return portsRegistryPath();
244
+ }
245
+
246
+ /** The current branch, or `null` when there is no repo or HEAD is detached. */
247
+ async function defaultBranch(projectDir: string): Promise<string | null> {
248
+ try {
249
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir });
250
+ const branch = stdout.trim();
251
+ return branch === "" || branch === "HEAD" ? null : branch;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Put this config's already-pinned block back into the registry if the registry has lost it.
259
+ *
260
+ * Gap-filling only — {@link reclaimPortBlocks} never overwrites a live allocation, so this can only ever
261
+ * restore a claim, never move one. Swallows its own failure: see {@link ensureDevConfig} for why a
262
+ * registry that cannot be written must not stop a session whose ports are already decided.
263
+ */
264
+ async function reregisterPinnedBlock(options: EnsureDevConfigOptions, branch: string, block: PortBlock): Promise<void> {
265
+ try {
266
+ const registryPath = await (options.registryPathFor ?? defaultRegistryPath)(options.projectDir);
267
+ const root = await (options.rootFor ?? registryRootFor)(options.projectDir);
268
+ await reclaimPortBlocks({ registryPath, root, reservations: [{ branch, block }] });
269
+ } catch {
270
+ // Nothing to report and nothing to stop: the ports this run uses are the ones already on disk.
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Guarantee this project has pinned ports, then return them — the bootstrap behind `pithy dev`.
276
+ *
277
+ * `.dev.config.json` is written at feature creation, but a plain `pithy init` project is the main checkout,
278
+ * which `pithy feature sync` deliberately refuses to touch — the main checkout is not a feature. So the
279
+ * scaffold's own `pithy dev` had no way to ever get one. This writes the port half; the `.dev.vars` half is
280
+ * {@link startDev}'s own step, because the two are needed in different projects at different moments.
281
+ *
282
+ * The invariant is unchanged: ports are **assigned** here, from the same central registry under the same
283
+ * file lock, then verified before anything binds — never probed at startup. Idempotent: a block is reused
284
+ * once allocated, and assignment is sticky, so a second run returns the same ports and a worker added later
285
+ * takes a free port without moving a sibling's address. An existing config keeps its own block and branch —
286
+ * the registry is never re-keyed underneath a live feature.
287
+ *
288
+ * **A pinned config still re-registers its claim, and that is not a contradiction of the line above**
289
+ * (#435). The registry is machine-wide now, so it can lose this project's entry to something this project
290
+ * never did: a wiped config directory, a new machine, a moved checkout pruned as gone by another project's
291
+ * allocation. Every one of those ends with a live feature's ports on offer to whoever allocates next.
292
+ * Before, the whole reclaim lived on the path that runs when there is *no* config — which is the path a
293
+ * settled project never takes, so `pithy dev`, the command anybody actually runs, repaired nothing. The
294
+ * repair is {@link reclaimPortBlocks}, which fills gaps and never overwrites, so re-registering a block
295
+ * this config already pins cannot move anyone: the promise above is about *re-keying*, and nothing here
296
+ * re-keys.
297
+ *
298
+ * Best-effort, deliberately. This session's ports are already pinned and are verified on both stacks
299
+ * before anything binds, so a registry that cannot be written is not a reason to refuse to start — an
300
+ * unwritable `$PITHY_CONFIG_DIR` used to leave `pithy dev` working off the pinned config alone, and it
301
+ * still does.
302
+ */
303
+ export async function ensureDevConfig(options: EnsureDevConfigOptions): Promise<DevConfig> {
304
+ const existing = options.existing ?? null;
305
+ const writeConfig = options.writeConfig ?? writeDevConfig;
306
+
307
+ let branch: string;
308
+ let block: PortBlock;
309
+ if (existing) {
310
+ branch = existing.branch;
311
+ block = { block: existing.ports.index, base: existing.ports.base, size: existing.ports.size };
312
+ await reregisterPinnedBlock(options, branch, block);
313
+ } else {
314
+ const registryPath = await (options.registryPathFor ?? defaultRegistryPath)(options.projectDir);
315
+ const root = await (options.rootFor ?? registryRootFor)(options.projectDir);
316
+ const named = await (options.branchFor ?? defaultBranch)(options.projectDir);
317
+ // Off a branch (no repo, detached HEAD) the checkout path is the stable key — one block per checkout.
318
+ branch = named ?? `local:${options.projectDir}`;
319
+ // Rebuild any registry entry lost since the worktrees were created, so a fresh registry can never hand
320
+ // out a block a live feature still holds. Scanned from the repository root, never from the registry's
321
+ // own directory: the file sits in the config directory now, which has no `.worktrees` and never will,
322
+ // so `dirname(registryPath)` would make this a silent no-op in every direction (#435).
323
+ await reclaimPortBlocks({ registryPath, root, reservations: await scanPinnedBlocks(root) });
324
+ block = await allocatePortBlock({ registryPath, root, branch });
325
+ }
326
+
327
+ const config = buildDevConfig({ branch, block, workers: options.workers, previous: existing });
328
+ await writeConfig(devConfigPath(options.projectDir), config);
329
+ return config;
330
+ }
331
+
332
+ /** Compile a worker's ready-signal regex, falling back to the default when the source is invalid. */
333
+ function readyRegexFor(worker: WorkerTarget): RegExp {
334
+ const source = (worker.dev ?? defaultWorkerDev()).readySignal;
335
+ try {
336
+ return new RegExp(source);
337
+ } catch {
338
+ return new RegExp(defaultWorkerDev().readySignal);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Start and supervise the local dev session — the engine behind `pithy dev`.
344
+ *
345
+ * It discovers the autostart workers, resolves each one's **pinned** port from `.dev.config.json` (bootstrapping
346
+ * one from the central port registry when the project has none — see {@link ensureDevConfig}), verifies
347
+ * every port is free on both loopback families before spawning anything (a conflict aborts the whole session
348
+ * — it never drifts to another port), stops any previous session and reaps orphaned workers, then spawns each
349
+ * worker as a process-group leader with its siblings' addresses wired into the env. Output is tee'd — colorized
350
+ * to the terminal, plain to `logs/dev.log` — and a single ready banner fires once every worker matches its
351
+ * ready signal. Returns a handle; signal wiring and process exit stay with the caller so the engine is testable.
352
+ */
353
+ export async function startDev(options: StartDevOptions): Promise<DevHandle> {
354
+ const projectDir = options.projectDir;
355
+ const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
356
+ const loadDevConfig = options.loadDevConfig ?? ((dir: string) => readDevConfig(devConfigPath(dir)));
357
+ const bind = options.tryBind ?? tryBindDefault;
358
+ const spawn = options.spawn ?? spawnDefault;
359
+ const kill = options.kill ?? ((pid, signal) => process.kill(pid, signal));
360
+ const isAlive = options.isAlive ?? isAliveDefault;
361
+ const sleep = options.sleep ?? realSleep;
362
+ const hasSetsid = options.hasSetsid ?? process.platform !== "win32";
363
+ const stdout = options.stdout ?? ((text: string) => void process.stdout.write(text));
364
+ const stderr = options.stderr ?? ((text: string) => void process.stderr.write(text));
365
+ const readDevLogin = options.readDevLogin ?? readDevLoginDefault;
366
+ const resolveDevLoginTargets =
367
+ options.devLoginTargets ??
368
+ ((started: readonly { name: string; dir: string; origin: string }[]) => devLoginTargetsDefault({ started }));
369
+ const readKeys = options.readKeys ?? readKeysDefault;
370
+ const openUrl = options.openUrl ?? ((url: string) => openUrlDefault(url));
371
+ const openLog = options.openLog ?? openLogDefault;
372
+ const now = options.now ?? (() => new Date());
373
+ const readState = options.readState ?? readDevState;
374
+ const writeState = options.writeState ?? writeDevState;
375
+ const removeState = options.removeState ?? removeDevState;
376
+ const ownPid = options.ownPid ?? process.pid;
377
+ /**
378
+ * Say one line to whoever is reading this session.
379
+ *
380
+ * **Under `--json`, stdout is reserved for JSON and the prose goes to stderr.** Everything a person is
381
+ * told here — the `Starting …` line, the delivery verdict, a `.dev.vars` refusal, and above all the
382
+ * workers' own teed output, which is the bulk of the stream and every line wrangler and Vite print —
383
+ * used to land on the same descriptor as the machine-readable line. So `pithy dev --json | jq` choked
384
+ * on the first thing wrangler said, and the only rule a consumer could apply was to try each line and
385
+ * skip what did not parse — which quietly skips a JSON line we get wrong, too. CLAUDE.md asks every
386
+ * command to be agent-drivable; a stream is only that if a script knows which lines are for it.
387
+ * Splitting by descriptor is the shell's own answer, costs a person nothing (both still reach the
388
+ * terminal, and `logs/dev.log` has every line in either mode), and gives the rule a consumer can
389
+ * actually apply: **every line on stdout is one object.** `docs/commands/dev.md` §`--json` states it.
390
+ */
391
+ const emitLine = (text: string) => (options.json ? stderr : stdout)(`${text}\n`);
392
+ /** The machine's half: one object per line, always on stdout, only under `--json`. */
393
+ const emitJson = (payload: Record<string, unknown>) => stdout(`${formatJsonLine(payload)}\n`);
394
+
395
+ // 1. Discover the autostart set. apps/ is the registry; no hand-kept list.
396
+ const discovered = await discoverWorkers(projectDir);
397
+
398
+ // …plus the host Worker of every capability those Workers compose (pithy-sh/pithy#410). Nine
399
+ // capabilities ship a prebuilt host that `pithy <capability> provision` deploys, none of them
400
+ // lives in `apps/`, and until now not one had ever run under `pithy dev` — which is why every
401
+ // email enqueued locally sat `pending` forever while the UI reported success. A host joins as an
402
+ // ordinary member: its own pinned port, label, color, state entry, and teardown. Discovery is
403
+ // through the shared registry, so the dev command names no capability.
404
+ //
405
+ // The project name is settled first, and `requireProjectName` rather than a guess: it is stamped
406
+ // into a Worker script name, and a guessed one differs between checkouts. A project that states
407
+ // none gets no hosts and one line saying so, rather than hosts running under a name nothing else
408
+ // in the project would reproduce.
409
+ const project = await (options.projectName ?? defaultProjectName)(projectDir);
410
+ const findHosts = options.discoverHostWorkers ?? discoverHostWorkersDefault;
411
+ const hostFinding =
412
+ project === null
413
+ ? {
414
+ hosts: [],
415
+ notes: [
416
+ "No project name in pithy.config.ts, so no capability host can be named — none will run.",
417
+ dim(' set: export default { name: "<project>" }'),
418
+ ],
419
+ }
420
+ : await findHosts({ projectDir, workers: discovered });
421
+ for (const line of hostFinding.notes) emitLine(line);
422
+ const hosts = hostFinding.hosts;
423
+ const hostNames = new Set(hosts.map((host) => host.worker.name));
424
+ const members = [...discovered, ...hosts.map((host) => host.worker)];
425
+ const autostart = members.filter((w) => (w.dev ?? defaultWorkerDev()).autostart);
426
+ if (autostart.length === 0) {
427
+ throw new ValidationError({
428
+ message: "No autostart workers to run.",
429
+ action: "Add one with pithy worker add, or set dev.autostart in a worker's pithy.worker.jsonc.",
430
+ });
431
+ }
432
+
433
+ // 2. Generate every worker's `.dev.vars`, before anything reads one (#154).
434
+ //
435
+ // wrangler loads the file beside the worker it runs, so each one needs its own. That was a symlink at
436
+ // a shared root file, and the symlink is the thing that never survived a clone: `pithy init` made it
437
+ // once for whoever created the project, and every developer after them cloned, wrote the `.dev.vars`
438
+ // the example told them to, and got nothing — every secret reported absent while the file sat at the
439
+ // root, unread. A `postinstall` could not fix it either, because the usual order is clone, install,
440
+ // *then* write `.dev.vars`. Generation removes the question: `pithy dev` is the command that runs
441
+ // every time, and it builds each file from the machine-local sources whether or not one is there.
442
+ //
443
+ // Idempotent by content, so a second `pithy dev` writes no bytes and wrangler's watcher sees nothing.
444
+ // A `.dev.vars` pithy did not generate is never overwritten and never merged — it is named, with the
445
+ // supported place for local values, and that worker starts without one rather than with somebody
446
+ // else's file replaced underneath it.
447
+ //
448
+ // Non-fatal in every direction. A project whose config directory is unreadable still starts, and
449
+ // says why its Workers have no bindings, because the alternative is a dev session that will not run
450
+ // at all over a file wrangler would have reported on itself.
451
+ const generate =
452
+ options.generateDevVars ??
453
+ ((dir: string, dirs: string[]) => generateDevVars({ projectDir: dir, workerDirs: dirs }));
454
+ const generateInto = async (dirs: string[]): Promise<void> => {
455
+ const devVars = await generate(projectDir, dirs);
456
+ for (const line of renderDevVarsNotes(devVars)) emitLine(line);
457
+ for (const line of devVars.unresolvable) emitLine(line);
458
+ };
459
+ try {
460
+ const devVars = await generate(
461
+ projectDir,
462
+ discovered.map((worker) => worker.dir),
463
+ );
464
+ for (const line of renderDevVarsNotes(devVars)) emitLine(line);
465
+ // A Worker whose `pithy.config.ts` would not import (#199). Emitted here rather than folded into
466
+ // `renderDevVarsNotes`, because this is not a delivery outcome: the file was written, and written
467
+ // empty on purpose. It is the one thing a `pithy dev` in this state has to say, and until now it
468
+ // said nothing — the session started, the Worker came up with no bindings, and the only line about
469
+ // it was `Starting <worker>.` Every run, not once: the state persists until the config is fixed,
470
+ // and the run after the one they missed is the one that has to reach them.
471
+ for (const line of devVars.unresolvable) emitLine(line);
472
+ } catch (error) {
473
+ emitLine(`.dev.vars not generated. ${messageOf(error)}`);
474
+ }
475
+
476
+ // 3. Resolve pinned ports from the dev config — never probe. A project that has none yet (a plain
477
+ // `pithy init` checkout, which `pithy feature sync` refuses to touch) gets one bootstrapped here from
478
+ // the same central registry, so ports stay assigned-then-verified rather than probed at startup.
479
+ const ensure = options.ensureDevConfig ?? ensureDevConfig;
480
+ const existing = await loadDevConfig(projectDir);
481
+ const unpinned = autostart.filter((w) => !existing?.workers[w.name]);
482
+ const config =
483
+ unpinned.length === 0 && existing
484
+ ? existing
485
+ : await ensure({ projectDir, workers: members, existing, ...(options.ensureDeps ?? {}) });
486
+
487
+ const started: { worker: WorkerTarget; port: number; origin: string }[] = [];
488
+ for (const worker of autostart) {
489
+ const pinned = config.workers[worker.name];
490
+ if (!pinned) {
491
+ throw new ValidationError({
492
+ message: `Worker "${worker.name}" has no port in .dev.config.json.`,
493
+ action: "Delete .dev.config.json and run pithy dev again to reassign this project's ports.",
494
+ });
495
+ }
496
+ started.push({ worker, port: pinned.port, origin: pinned.origin });
497
+ }
498
+
499
+ // 3b. Resolve and write each host's local `wrangler.jsonc`, now that the app Worker's own address
500
+ // is known — a message sent from here builds its callback links against it.
501
+ //
502
+ // The delivery preflight runs first and *decides*. `remote: true` on the email host's send
503
+ // binding runs the Worker locally and delivers through Cloudflare Email Service for real, which
504
+ // is what makes a magic link triggered from localhost actually arrive; that needs a Cloudflare
505
+ // login and an onboarded sending domain, neither of which the kit owns. Where the cheap check
506
+ // can already see one of them is missing, the host is resolved for its local simulator instead
507
+ // of for a binding that would fail at startup — and it says so, before anyone is waiting on an
508
+ // inbox. The preflight is not the guarantee: `deliveryFailureNote` watches the host's own
509
+ // output for the failures it cannot see from here.
510
+ const hostPorts: Record<string, number> = {};
511
+ for (const host of hosts) {
512
+ const pinned = config.workers[host.worker.name];
513
+ if (pinned) hostPorts[host.worker.name] = pinned.port;
514
+ }
515
+ // The delivery verdict, said **once** — in the ready banner, which is where a developer looks, and
516
+ // pre-spawn only under `--json`, where there is no banner and the reader is a script. Saying it in
517
+ // both places was two copies of one sentence in every interactive session.
518
+ let deliveryLines: readonly string[] = [];
519
+ // The hosts that actually have a config on disk, the seam that wrote it, and the address it wrote
520
+ // them against — all three needed after the block below: only these are started, and a delivery
521
+ // failure at runtime rewrites one of them for its simulator.
522
+ let liveHosts: HostWorker[] = hosts;
523
+ let materializeHosts: ((options: MaterializeHostConfigsOptions) => Promise<HostMaterialization>) | undefined;
524
+ let hostBaseUrl = "http://localhost";
525
+ let deliveryIsLive = false;
526
+ if (project !== null && hosts.length > 0) {
527
+ // The app's address: the first started Worker that is not a host. Callback links point at the
528
+ // app, never at the host — the host holds no public route of its own.
529
+ const app = started.find((s) => !hostNames.has(s.worker.name));
530
+ const identity = await hostDeliveryIdentity(hosts);
531
+ const preflight = deliveryPreflight({
532
+ composed: identity !== undefined,
533
+ requested: identity?.requested ?? "remote",
534
+ fromAddress: identity?.fromAddress,
535
+ hasCloudflareLogin: await (options.hasCloudflareLogin ?? defaultHasCloudflareLogin)(
536
+ projectDir,
537
+ options.baseEnv ?? process.env,
538
+ ),
539
+ });
540
+ deliveryLines = preflight.lines;
541
+ deliveryIsLive = preflight.live;
542
+ if (options.json) for (const line of preflight.lines) emitLine(line);
543
+ hostBaseUrl = app?.origin ?? started[0]?.origin ?? "http://localhost";
544
+ const materialize = options.materializeHostConfigs ?? materializeHostConfigsDefault;
545
+ materializeHosts = materialize;
546
+ const materialized = await materialize({
547
+ projectDir,
548
+ project,
549
+ baseUrl: hostBaseUrl,
550
+ hosts,
551
+ simulateDelivery: !preflight.live,
552
+ });
553
+ for (const line of materialized.notes) emitLine(line);
554
+ // A host with no config on disk leaves the set here, and that is the whole point of the second
555
+ // list. Its directory was never created, so `wrangler dev` in it fails on the spawn itself — Node
556
+ // raises `error`, the handler below tears the session down, and every Worker that was running fine
557
+ // dies for one capability nobody could resolve. The note said "it will not run"; this is what makes
558
+ // that true. Its siblings' `<STEM>_ORIGIN` goes with it, because an address nothing listens on is
559
+ // worse than none: the loopback dispatcher prefers a published origin over the binding.
560
+ const dropped = new Set(materialized.failed);
561
+ if (dropped.size > 0) {
562
+ for (let index = started.length - 1; index >= 0; index -= 1) {
563
+ if (dropped.has(started[index]?.worker.name ?? "")) started.splice(index, 1);
564
+ }
565
+ for (const name of dropped) delete hostPorts[name];
566
+ }
567
+ liveHosts = hosts.filter((host) => !dropped.has(host.worker.name));
568
+ // A host's `.dev.vars` is generated once its directory exists, from the same project-wide
569
+ // bootstrap set every Worker gets — the master key above all, since a local host has no Secrets
570
+ // Store for the resolved template's entries to point at (which is why that block is dropped).
571
+ // Through the one generator, so a `.dev.vars` value is never written by a second hand.
572
+ try {
573
+ await generateInto(liveHosts.map((host) => host.worker.dir));
574
+ } catch (error) {
575
+ emitLine(`Capability hosts start without secrets. ${messageOf(error)}`);
576
+ }
577
+ }
578
+
579
+ // 4. Stop a previous session, then reap orphaned workerd/wrangler still holding the pinned ports. This runs
580
+ // BEFORE verification: a crashed prior session's orphan must be reaped, not treated as an external
581
+ // conflict that blocks startup (docs/CLI.md §6.2 — a crashed session can't block the next one). The
582
+ // sweep is scoped to our own orphans — the previous session's pids and workerd/wrangler-shaped
583
+ // commands — so anything genuinely external falls through to step 5 and is reported, never killed.
584
+ const statePath = devStatePath(projectDir);
585
+ const previous = await stopPreviousSession({ statePath, readState, isAlive, kill, sleep, emitLine });
586
+ const sweep =
587
+ options.sweep ??
588
+ ((ports: number[], knownPids: readonly number[]) =>
589
+ sweepStaleDevPorts(ports, { knownPids, selfPid: ownPid, log: (m) => emitLine(m) }));
590
+ await sweep(
591
+ started.map((s) => s.port),
592
+ previous?.childPids ?? [],
593
+ );
594
+
595
+ // 5. Verify every pinned port is now free on both loopback families — one conflict (something genuinely
596
+ // external still holds it) aborts the whole session with one error; it never drifts to another port.
597
+ for (const { worker, port } of started) {
598
+ await verifyPinnedPort(worker.name, port, bind);
599
+ }
600
+
601
+ // 6. Resolve the wrangler launcher through the project's package manager (never a hardcoded global).
602
+ const launchWrangler =
603
+ options.launchWrangler ??
604
+ (await (async () => {
605
+ const pm = await detectPackageManager(projectDir);
606
+ return (args: string[]) => execArgs(pm, "wrangler", args);
607
+ })());
608
+
609
+ // 7. Open the log, wire the shared env, and spawn.
610
+ const logPath = join(projectDir, "logs", "dev.log");
611
+ // Read before anything spawns, so the banner never waits on the disk once the workers are up.
612
+ const devLogin = await readDevLogin(projectDir);
613
+ const log = openLog(logPath);
614
+ log.write(
615
+ `=== dev session ${now().toISOString()} — ${started.map((s) => `${s.worker.name}:${s.port}`).join(", ")} ===`,
616
+ );
617
+ const baseEnv = options.baseEnv ?? process.env;
618
+ // The keypress follows the route. Under CI the auth capability registers none, so `l` would open a
619
+ // 404 — the read is the same one the capability makes, from the same module, and it is the only
620
+ // refusal the supervisor can see coming rather than discover.
621
+ const ci = isContinuousIntegration(baseEnv);
622
+ // Which running workers carry `GET /__pithy/dev-login` — the ones composing auth. Resolved only when
623
+ // there is a session to open, so a project with no dev login never loads a Worker config for this.
624
+ const devLoginWorkers: DevLoginTarget[] =
625
+ devLogin && !ci
626
+ ? await resolveDevLoginTargets(
627
+ started
628
+ .filter((s) => !hostNames.has(s.worker.name))
629
+ .map((s) => ({ name: s.worker.name, dir: s.worker.dir, origin: s.origin })),
630
+ )
631
+ : [];
632
+ const childEnv = buildWorkerEnv(config, baseEnv);
633
+ // One local store for the whole project, named in one place — `localDevStateRoot`. This used to compose
634
+ // the path itself, which made three independent statements of one directory (#404).
635
+ const persistTo = localDevStateRoot(projectDir);
636
+
637
+ const children: { name: string; child: ChildLike }[] = [];
638
+ const pipes: Promise<void>[] = [];
639
+ const exits: Promise<void>[] = [];
640
+ const readyState = new Map<string, boolean>();
641
+ const readyRegex = new Map<string, RegExp>();
642
+ let bannerShown = false;
643
+ let resolveReady!: () => void;
644
+ const ready = new Promise<void>((resolve) => {
645
+ resolveReady = resolve;
646
+ });
647
+ let resolveClosed!: () => void;
648
+ const closed = new Promise<void>((resolve) => {
649
+ resolveClosed = resolve;
650
+ });
651
+ let shuttingDown = false;
652
+
653
+ // The ready deadline's timer, replaced by the live watch once the children are spawned. Declared here
654
+ // for the same reason `keys` is: the banner and the shutdown both stop it, and both are written above
655
+ // the spawn loop that starts it.
656
+ let readyWatch: ReadyWatch = { stop: () => {} };
657
+
658
+ const showBannerIfReady = () => {
659
+ if (bannerShown || [...readyState.values()].some((r) => !r)) return;
660
+ bannerShown = true;
661
+ readyWatch.stop();
662
+ if (!options.json) {
663
+ // Bindings go live with the banner, not before it: `l` opens a URL, and a URL that answers is a
664
+ // worker that has already matched its ready signal. `--json` gets none — its output is being read
665
+ // by a script, and a supervisor that entered raw mode for a machine would be holding a terminal
666
+ // nobody is at.
667
+ startKeys();
668
+ emitLine("Ready.");
669
+ for (const s of started) emitLine(`${s.worker.name}: ${s.origin}`);
670
+ // Said once, where a developer actually looks. Real delivery or the simulator is the difference
671
+ // between a magic link arriving and a rendered file on disk, and nobody should learn it from an
672
+ // inbox that stays empty. Every line of the verdict, action included — a sentence naming the
673
+ // problem without the sentence naming the fix is half a report.
674
+ for (const line of deliveryLines) emitLine(line);
675
+ // The banner is the discovery mechanism. A seeded session nobody finds has removed no friction, and
676
+ // the line below is the only place a developer reliably looks after `pithy dev`. It says that there
677
+ // is a session and how to reach it — never what the session *is*.
678
+ for (const line of devLoginLines(devLogin, now(), { interactive: keys.active, targets: devLoginWorkers, ci })) {
679
+ emitLine(line);
680
+ }
681
+ emitLine(dim(`logs → ${logPath}`));
682
+ }
683
+ for (const s of started) log.write(`ready: ${s.worker.name} ${s.origin}`);
684
+ resolveReady();
685
+ };
686
+
687
+ /**
688
+ * `l` — open a signed-in browser.
689
+ *
690
+ * The decision is {@link devLoginKeyAction}'s and is made without touching the terminal, so the only
691
+ * work here is saying it and handing the URL over. A failed open is reported and survived: `pithy dev`
692
+ * supervises workers, and no browser is a reason for a sentence, not for tearing a session down.
693
+ */
694
+ const openDevLogin = async (): Promise<void> => {
695
+ const action = devLoginKeyAction(devLogin, now(), devLoginWorkers, ci);
696
+ for (const line of action.lines) emitLine(line);
697
+ if (!action.url) return;
698
+ try {
699
+ await openUrl(action.url);
700
+ } catch (error) {
701
+ emitLine(messageOf(error));
702
+ }
703
+ };
704
+
705
+ // Scoped to `l`. A second binding is one more entry here — `r` to restart and `o` to open the app are
706
+ // the obvious neighbors — and neither is this issue.
707
+ let keys: KeyReader = { active: false, stop: () => {} };
708
+ const startKeys = () => {
709
+ keys = readKeys({
710
+ bindings: [{ key: "l", run: openDevLogin }],
711
+ // Raw mode takes the terminal's own Ctrl-C handling away, so the supervisor has to put it back.
712
+ // Without this line `pithy dev` becomes unstoppable from the keyboard.
713
+ onInterrupt: () => void shutdown("interrupted"),
714
+ onError: (error) => emitLine(messageOf(error)),
715
+ });
716
+ };
717
+
718
+ const signalChild = (pid: number | undefined, signal: NodeJS.Signals) => {
719
+ if (!pid) return;
720
+ try {
721
+ kill(hasSetsid ? -pid : pid, signal);
722
+ } catch {
723
+ // Already exited (ESRCH) — nothing to signal.
724
+ }
725
+ };
726
+
727
+ const shutdown = async (reason: string): Promise<void> => {
728
+ if (shuttingDown) return;
729
+ shuttingDown = true;
730
+ // First, before anything can take time: give the terminal back. A session that died with the
731
+ // terminal in raw mode leaves a shell that echoes nothing.
732
+ keys.stop();
733
+ readyWatch.stop();
734
+ emitLine(`Stopping — ${reason}.`);
735
+ log.write(`stopping — ${reason}`);
736
+ for (const { child } of children) signalChild(child.pid, "SIGTERM");
737
+ const allExited = Promise.allSettled(exits);
738
+ const timedOut = await Promise.race([allExited.then(() => false), sleep(SHUTDOWN_GRACE_MS).then(() => true)]);
739
+ if (timedOut) {
740
+ emitLine("Children still alive after grace window — sending SIGKILL.");
741
+ for (const { child } of children) signalChild(child.pid, "SIGKILL");
742
+ await allExited;
743
+ }
744
+ await Promise.allSettled(pipes);
745
+ await log.end();
746
+ removeState(statePath, ownPid);
747
+ resolveClosed();
748
+ };
749
+
750
+ // The entitlement composition check, reported once at startup. The seam fails closed, so a Worker that
751
+ // gates routes on an entitlement while composing no provider denies every one of them — and at runtime
752
+ // that is indistinguishable from a project full of unentitled users. Non-fatal: it is a warning about
753
+ // wiring, not a reason to refuse to run, and a config that will not load is left to wrangler to report.
754
+ const checkEntitlements = options.checkEntitlements ?? defaultCheckEntitlements;
755
+ for (const { worker } of started) {
756
+ const gates = await checkEntitlements(worker.dir);
757
+ if (gates.length === 0) continue;
758
+ emitLine(`${worker.name}: routes gate on an entitlement, but no capability resolves one — they will deny.`);
759
+ for (const gate of gates) emitLine(dim(` ${gate}`));
760
+ emitLine(dim(" run: pithy add payments"));
761
+ }
762
+
763
+ // Secrets are seeded before anything spawns, for the same reason the `.dev.vars` link is wired before
764
+ // anything spawns (#139): a Worker reads its secrets on the first request, and a store seeded after
765
+ // startup is a store the first sign-in of the session missed. Idempotent, so this is silent on every
766
+ // run but the one that changed something.
767
+ //
768
+ // Non-fatal, in both directions. A project that never composed `secrets` has nothing to seed and
769
+ // hears nothing. A dev secrets file that will not parse is said out loud and the session still
770
+ // starts — refusing to run every Worker over one malformed file would be a worse trade than letting
771
+ // the capability that needs the secret fail with its own error.
772
+ const seedSecrets = options.seedSecrets ?? ((dir: string) => seedProjectDevSecrets({ projectDir: dir }));
773
+ try {
774
+ for (const line of renderDevSecretsNotes(await seedSecrets(projectDir))) emitLine(line);
775
+ } catch (error) {
776
+ emitLine(`Secrets not seeded. ${messageOf(error)}`);
777
+ }
778
+
779
+ /**
780
+ * The runtime half of the delivery fallback (pithy-sh/pithy#410).
781
+ *
782
+ * The preflight decides what it can see from outside the process; a remote `send_email` binding that
783
+ * will not stand up, and a send Cloudflare refuses, appear only in the host's own output. Reporting
784
+ * that and stopping there leaves the session in the one state the issue forbids — every subsequent
785
+ * magic link failing, quietly, for the rest of the afternoon. So the host is re-resolved for its
786
+ * local simulator, which sends nothing and logs the recipient, subject and URL.
787
+ *
788
+ * **Rewriting the config is the whole restart.** `wrangler dev` watches the `wrangler.jsonc` it was
789
+ * started with and reloads the Worker when it changes, so the fallback needs no second spawn path,
790
+ * no kill that the exit handler would read as a crash, and no port to re-verify.
791
+ *
792
+ * Once per host. A failing binding usually says so more than once, and a rewrite loop would reload
793
+ * the Worker on every line it printed.
794
+ */
795
+ const simulated = new Set<string>();
796
+ const fallBackToSimulator = async (capability: string): Promise<void> => {
797
+ // Nothing to fall back to when this session was never sending for real: the host already holds the
798
+ // simulator, and rewriting an identical config would reload a Worker for no change.
799
+ if (!deliveryIsLive || simulated.has(capability) || !materializeHosts || project === null) return;
800
+ const host = liveHosts.find((candidate) => candidate.worker.name === capability);
801
+ if (!host) return;
802
+ simulated.add(capability);
803
+ try {
804
+ const again = await materializeHosts({
805
+ projectDir,
806
+ project,
807
+ baseUrl: hostBaseUrl,
808
+ hosts: [host],
809
+ simulateDelivery: true,
810
+ });
811
+ for (const line of again.notes) emitLine(line);
812
+ emitLine(`${capability}: using the simulator from here. Messages are logged and written to disk, never sent.`);
813
+ } catch (error) {
814
+ emitLine(`${capability}: the simulator fallback could not be written. ${messageOf(error)}`);
815
+ }
816
+ };
817
+
818
+ emitLine(`Starting ${started.map((s) => s.worker.name).join(", ")}.`);
819
+
820
+ for (const { worker, port, origin } of started) {
821
+ readyState.set(worker.name, false);
822
+ readyRegex.set(worker.name, readyRegexFor(worker));
823
+ // A host is handed no origin of its own: `materializeHostConfigs` already wrote the app's into its
824
+ // generated config, which is the address its callback links must carry. See `ownOriginFor`.
825
+ const ownOrigin = ownOriginFor(worker.name, origin, hostNames);
826
+ const { command, args } = startCommand(worker, port, ownOrigin, launchWrangler, persistTo, baseEnv, hostPorts);
827
+ // Both carriers, from the one value above. The argv `--var` reaches a `wrangler dev`; the
828
+ // environment reaches a custom `dev.command`, where there is no argv to append to and
829
+ // `@pithy-sh/vite` turns it into the same binding.
830
+ const env = childEnvFor(childEnv, ownOrigin);
831
+ const child = spawn(command, args, { cwd: worker.dir, env, detached: hasSetsid });
832
+ children.push({ name: worker.name, child });
833
+
834
+ const isHost = hostNames.has(worker.name);
835
+ const onLine = (line: string) => {
836
+ // A remote send binding that will not stand up, or a send Cloudflare refuses, appears here and
837
+ // nowhere else — the preflight above cannot see either from outside the process. Caught where it
838
+ // appears, rendered with the action that fixes it, then the host is dropped to its simulator so
839
+ // the rest of the session still sends something. Never fatal: `pithy dev` supervises Workers, and
840
+ // a message that did not send is a reason for a sentence, not for a teardown.
841
+ if (isHost) {
842
+ const note = deliveryFailureNote(line);
843
+ if (note) {
844
+ for (const text of note.split("\n")) emitLine(text);
845
+ void fallBackToSimulator(worker.name);
846
+ }
847
+ }
848
+ if (bannerShown || readyState.get(worker.name)) return;
849
+ if (readyRegex.get(worker.name)?.test(line)) {
850
+ readyState.set(worker.name, true);
851
+ showBannerIfReady();
852
+ }
853
+ };
854
+ const paint = workerColor(children.length - 1);
855
+ if (child.stdout) {
856
+ pipes.push(
857
+ teeStream({
858
+ stream: child.stdout,
859
+ label: worker.name,
860
+ paint,
861
+ sinks: { terminal: (l) => emitLine(l), log: (l) => log.write(l), line: onLine },
862
+ }),
863
+ );
864
+ }
865
+ if (child.stderr) {
866
+ pipes.push(
867
+ teeStream({
868
+ stream: child.stderr,
869
+ label: worker.name,
870
+ paint,
871
+ sinks: { terminal: (l) => emitLine(l), log: (l) => log.write(l), line: onLine },
872
+ }),
873
+ );
874
+ }
875
+
876
+ exits.push(
877
+ new Promise<void>((resolve) => {
878
+ child.once("exit", (code) => {
879
+ resolve();
880
+ if (!shuttingDown) void shutdown(`${worker.name} exited (${code})`);
881
+ });
882
+ // A spawn failure (ENOENT for a missing dev.command binary, EACCES, …) emits 'error' and never 'exit'.
883
+ // Without this listener Node re-throws it as an uncaught error, crashing dev with a raw stack and never
884
+ // shutting down. Handle it: report it, settle this child's exit, and tear the session down.
885
+ child.once("error", (error) => {
886
+ emitLine(`${worker.name} failed to start: ${error.message}`);
887
+ log.write(`error: ${worker.name} ${error.message}`);
888
+ resolve();
889
+ if (!shuttingDown) void shutdown(`${worker.name} failed to start`);
890
+ });
891
+ }),
892
+ );
893
+ }
894
+
895
+ // 8. Start the ready deadline, now that every child is running (pithy-sh/pithy#429).
896
+ //
897
+ // `wrangler dev` does not exit when a build fails — it prints the error and keeps running. So a
898
+ // worker that cannot build is a live child that never matches its ready signal: the banner waits on
899
+ // the whole set and never fires, and the session proceeds looking healthy with the real error forty
900
+ // lines up the scrollback, interleaved with every sibling's startup. Three capability workers reached
901
+ // an adopter that way (#426). The deadline names whoever has not arrived, and keeps naming them.
902
+ //
903
+ // **A child that fails to build is deliberately not treated as dead — it is reported, and left.**
904
+ // The tempting alternative loses on three counts. A death here is not local: the exit handler above
905
+ // tears the *whole* session down when any child exits, so condemning one broken build would stop
906
+ // every healthy worker for one worker's typo, which is a worse trade than a line naming it. The
907
+ // verdict would have to be read out of wrangler's own output (`Build failed with 1 error`), which
908
+ // is version-coupled prose, and a false positive kills a working session — while a `dev.command`
909
+ // worker is not wrangler at all, and Vite does recover from a bad build. And the deadline already
910
+ // catches strictly more than a build failure: a port that never binds, a binding that never
911
+ // resolves, a startup that hangs. So the watch reports; it never condemns.
912
+ //
913
+ // It does say what a restart cannot be avoided for. A `wrangler dev` whose **first** build fails
914
+ // never rebuilds — fixing the file changes nothing, measured, so the report's action line names
915
+ // `pithy dev` rather than implying the session will heal itself.
916
+ //
917
+ // **`--json` gets a record, not the prose.** CLAUDE.md makes every command agent-drivable, and the
918
+ // agent driving `pithy dev --json` is in exactly the position #426's adopter was: a session that
919
+ // never emits its ready line, and nothing on the wire saying which worker is missing. A sentence it
920
+ // would have to regex is not an answer, so the deadline emits one JSON line per report — the same
921
+ // line-per-object shape as the handshake above it, `event` naming which kind of line it is. That is
922
+ // the one place `pithy dev`'s streaming surface owes a machine something the handshake cannot carry:
923
+ // the handshake is written the moment the children are spawned, and readiness is decided after it.
924
+ // The prose still goes to `logs/dev.log` in both modes — the log is read by a person either way.
925
+ readyWatch = watchReady({
926
+ pending: () => started.map((s) => s.worker.name).filter((name) => !readyState.get(name)),
927
+ report: (waiting, first) => {
928
+ // Both destinations, the way the banner's own lines go: a report only in the terminal is a report
929
+ // a piped session loses, and `logs/dev.log` is where a developer looks after the fact.
930
+ const lines = stillWaitingLines(waiting, first);
931
+ if (options.json) {
932
+ emitJson({ command: "dev", event: "still-waiting", waiting: [...waiting] });
933
+ } else {
934
+ for (const line of lines) emitLine(line);
935
+ }
936
+ for (const line of lines) log.write(stripAnsi(line));
937
+ },
938
+ schedule: options.schedule,
939
+ });
940
+
941
+ // 9. Record the live session so a re-run can stop it and reap its children.
942
+ const state: DevState = {
943
+ pid: ownPid,
944
+ startedAt: now().toISOString(),
945
+ childPids: children.map((c) => c.child.pid).filter((pid): pid is number => typeof pid === "number"),
946
+ workers: Object.fromEntries(
947
+ children.map(({ name, child }, i) => [name, { port: started[i]?.port ?? 0, pid: child.pid ?? 0 }]),
948
+ ),
949
+ };
950
+ await writeState(statePath, state);
951
+
952
+ return {
953
+ workers: started.map((s) => ({ name: s.worker.name, port: s.port, origin: s.origin })),
954
+ ready,
955
+ closed,
956
+ shutdown,
957
+ state,
958
+ };
959
+ }
960
+
961
+ /**
962
+ * Stop a still-running previous session, or reap the orphaned children of a crashed one. Returns the state
963
+ * it read, so the port sweep knows which pids were ours and can leave every other one to be reported.
964
+ */
965
+ async function stopPreviousSession(deps: {
966
+ statePath: string;
967
+ readState: (path: string) => Promise<DevState | null>;
968
+ isAlive: (pid: number) => boolean;
969
+ kill: (pid: number, signal: NodeJS.Signals) => void;
970
+ sleep: Sleep;
971
+ emitLine: (text: string) => void;
972
+ }): Promise<DevState | null> {
973
+ const prev = await deps.readState(deps.statePath);
974
+ if (!prev) return null;
975
+ if (deps.isAlive(prev.pid)) {
976
+ deps.emitLine(`Stopping previous session (pid ${prev.pid}).`);
977
+ trySignal(deps.kill, prev.pid, "SIGINT");
978
+ const gone = await waitFor(prev.pid, 5000, deps.isAlive, deps.sleep);
979
+ if (!gone) {
980
+ trySignal(deps.kill, prev.pid, "SIGKILL");
981
+ await waitFor(prev.pid, 2000, deps.isAlive, deps.sleep);
982
+ }
983
+ return prev;
984
+ }
985
+ for (const pid of prev.childPids) {
986
+ if (deps.isAlive(pid)) {
987
+ deps.emitLine(`Reaping orphan child pid ${pid}.`);
988
+ trySignal(deps.kill, pid, "SIGTERM");
989
+ }
990
+ }
991
+ return prev;
992
+ }
993
+
994
+ function trySignal(kill: (pid: number, signal: NodeJS.Signals) => void, pid: number, signal: NodeJS.Signals): void {
995
+ try {
996
+ kill(pid, signal);
997
+ } catch {
998
+ // Already gone.
999
+ }
1000
+ }
1001
+
1002
+ async function waitFor(
1003
+ pid: number,
1004
+ timeoutMs: number,
1005
+ isAlive: (pid: number) => boolean,
1006
+ sleep: Sleep,
1007
+ ): Promise<boolean> {
1008
+ const start = Date.now();
1009
+ for (;;) {
1010
+ if (!isAlive(pid)) return true;
1011
+ if (Date.now() - start > timeoutMs) return false;
1012
+ await sleep(100);
1013
+ }
1014
+ }