@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,290 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mkdir } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join } from "node:path";
6
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
7
+ import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
9
+ import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
10
+ import { composeCapabilities } from "../capabilities/compose";
11
+ import {
12
+ HOST_WORKERS,
13
+ type HostDeliveryIdentity,
14
+ type HostWorkerSpec,
15
+ hostTemplatePath,
16
+ hostWorkerFor,
17
+ readHostTemplate as readHostTemplateDefault,
18
+ } from "../capabilities/hostRegistry";
19
+ import { writeFileAtomic } from "../project/atomic";
20
+ import { allCapabilities, loadWorkerConfig } from "../project/config";
21
+ import { DEFAULT_READY_SIGNAL } from "../project/workerManifest";
22
+ import type { WorkerTarget } from "../project/workers";
23
+
24
+ /**
25
+ * **The dev set is app Workers plus the host Worker of every capability they compose.**
26
+ *
27
+ * `apps/` is the app-Worker registry and stays exactly that. Beside it, each composed capability that
28
+ * owns Workflows contributes the prebuilt host Worker `pithy <capability> provision` would deploy —
29
+ * resolved through the same seam the provisioners use ({@link HOST_WORKERS}), materialized into a
30
+ * git-ignored directory, and handed to the orchestrator as an ordinary {@link WorkerTarget}. From
31
+ * there it is not a special case: it gets a pinned port out of `.dev.config.json`, a label and a
32
+ * color, an entry in `.dev-state.json`, and it is reaped with everything else.
33
+ *
34
+ * ## The name is the wire
35
+ *
36
+ * A host is registered under its **capability name** — `email`, not `acme-dev-email`. `buildWorkerEnv`
37
+ * derives `<STEM>_ORIGIN` from whatever name a worker carries in the dev config, so `email` is what
38
+ * makes an app Worker's `EMAIL_ORIGIN` resolve, and that is the address core's loopback dispatcher
39
+ * posts a Workflow dispatch to. A host named after its deployed script would publish
40
+ * `ACME_DEV_EMAIL_ORIGIN`, which nothing looks up. An `apps/` Worker already holding that name is
41
+ * therefore refused rather than shadowed — the wire would be silently wrong, which is the whole
42
+ * failure this issue is about.
43
+ *
44
+ * ## Where the config lands, and why it is generated at all
45
+ *
46
+ * Under `<project>/.wrangler/pithy/hosts/<capability>/`, following `provision/featureConfig.ts`: every
47
+ * scaffolded project has ignored `.wrangler/` at any depth since the first release, so a generated
48
+ * config can never be committed and `git add -A` cannot reach it. It is generated rather than shipped
49
+ * because a host template is a *template* — its database ids, its base URL and its theme are the
50
+ * adopter's, and only a resolution knows them.
51
+ *
52
+ * Two dev-shaped edits happen after the capability's own resolver has run, and both are here rather
53
+ * than in the resolvers because both are true of every host at once:
54
+ *
55
+ * - **`main` becomes absolute.** wrangler resolves `main` relative to the config file, and this config
56
+ * sits nowhere near the entry it names — the entry is inside `node_modules`.
57
+ * - **`secrets_store_secrets` is dropped.** There is no Secrets Store locally; the master key reaches
58
+ * a local Worker as a `.dev.vars` value, exactly as it reaches the app Worker.
59
+ *
60
+ * Database ids are the third, and are the registry's own job: {@link HostResolveContext.databaseId}
61
+ * answers the binding name, because local D1 identity is `database_id ?? binding` — wrangler's chain,
62
+ * and the one `pithy migrate --env dev` keyed the Miniflare store by. A host resolved with anything
63
+ * else opens an empty database and fails `no such table` on its first query.
64
+ */
65
+
66
+ /** Where a materialized host config lives, under the already-ignored `.wrangler/`. */
67
+ export function hostWorkerDir(projectDir: string, capability: string): string {
68
+ return join(projectDir, ".wrangler", "pithy", "hosts", capability);
69
+ }
70
+
71
+ /** One discovered host: the dev-set member, the capability it belongs to, and how to resolve it. */
72
+ export interface HostWorker {
73
+ /** The capability that owns the host — its dev-set name and the key into the registry. */
74
+ capability: string;
75
+ /** The dev-set member handed to the orchestrator. Ordinary in every respect. */
76
+ worker: WorkerTarget;
77
+ /** The registry entry that fills this host's template. */
78
+ spec: HostWorkerSpec;
79
+ /** The composed capability object, when a Worker's config carried one this resolver can read. */
80
+ composed?: Capability;
81
+ /**
82
+ * The app Worker whose composition brought this host into the set — the directory its `.dev.vars`
83
+ * is generated into, and therefore the one this host copies. A host reads the same secrets the
84
+ * Worker that composes it does (the master key above all), and it has no `pithy.config.ts` of its
85
+ * own for the generator to resolve targets from.
86
+ */
87
+ sourceDir: string;
88
+ }
89
+
90
+ /** What discovery found, and anything it had to survive on the way. */
91
+ export interface HostWorkerDiscovery {
92
+ hosts: HostWorker[];
93
+ /** Non-fatal lines for the terminal. A Worker whose config will not load is one; silence would not be. */
94
+ notes: string[];
95
+ }
96
+
97
+ /** Options for {@link discoverHostWorkers}. */
98
+ export interface DiscoverHostWorkersOptions {
99
+ projectDir: string;
100
+ /** Every discovered app Worker. Their composed capabilities decide the host set. */
101
+ workers: readonly WorkerTarget[];
102
+ /** Seam: the capabilities one Worker composes. Defaults to loading its `apps/<name>/pithy.config.ts`. */
103
+ capabilitiesFor?: (workerDir: string) => Promise<Capability[]>;
104
+ }
105
+
106
+ /** The dev block a host Worker runs under: it must be up for the local loop to work, so it autostarts. */
107
+ function hostDev(): WorkerTarget["dev"] {
108
+ return { autostart: true, readySignal: DEFAULT_READY_SIGNAL };
109
+ }
110
+
111
+ /**
112
+ * Every host Worker the project's app Workers compose, in registry order.
113
+ *
114
+ * A Worker whose `pithy.config.ts` will not load contributes nothing and is named — the same trade
115
+ * the entitlement check makes. `pithy dev` reports wiring; a config that will not import is
116
+ * wrangler's error to raise, and refusing to run the whole session over it would be a worse trade.
117
+ */
118
+ export async function discoverHostWorkers(options: DiscoverHostWorkersOptions): Promise<HostWorkerDiscovery> {
119
+ const capabilitiesFor =
120
+ options.capabilitiesFor ?? (async (dir: string) => allCapabilities(await loadWorkerConfig(dir)));
121
+
122
+ const notes: string[] = [];
123
+ const composed = new Map<string, { capability: Capability; dir: string }>();
124
+ for (const worker of options.workers) {
125
+ let capabilities: Capability[];
126
+ try {
127
+ capabilities = await capabilitiesFor(worker.dir);
128
+ } catch (error) {
129
+ notes.push(`${worker.name}: its capabilities could not be read, so its capability hosts will not run.`);
130
+ notes.push(` ${messageOf(error)}`);
131
+ continue;
132
+ }
133
+ // First declaration wins. Two Workers composing one capability share one host, exactly as they
134
+ // share a D1 by declaring one binding name — the host is the capability's, not the Worker's.
135
+ for (const capability of capabilities) {
136
+ if (!composed.has(capability.name)) composed.set(capability.name, { capability, dir: worker.dir });
137
+ }
138
+ }
139
+
140
+ // **Assembled before a single host reads one of them.** A capability object is a placeholder for
141
+ // half of what it reports until its `compose` hook has run, and the email host's resolver reads
142
+ // exactly such a value: `hostCatalogs()` answers `{}` on an unassembled capability, so `pithy dev`
143
+ // materialized an English-only host for a project that speaks two languages. The app Worker enqueued
144
+ // a Spanish subject and stored `locale='es'`; the local host re-rendered in English and — because
145
+ // `runSend` overwrites the stored subject with its own render — threw the Spanish one away. That is
146
+ // the two-Workers-disagree failure the `EMAIL_MESSAGES` var exists to close, reproduced on a
147
+ // developer's machine. See `capabilities/compose.ts`, and `emailProvisioner.ts` for the deployed half.
148
+ //
149
+ // **Degraded, not fatal, for the same reason the read above is.** A compose hook throws — `auth`
150
+ // refuses a plugin claiming a kit table, `payments` checks its ledger grants — and this sweep runs
151
+ // them over the **project-wide union** rather than any one Worker's own set. So a pair of Workers can
152
+ // collide here on something neither of them would refuse at its own boot, and an uncaught throw would
153
+ // end the whole `pithy dev` session rather than the one host it concerns. A session that starts
154
+ // without the email host and says why is worth more than one that does not start.
155
+ try {
156
+ composeCapabilities([...composed.values()].map((entry) => entry.capability));
157
+ } catch (error) {
158
+ notes.push("The capability hosts could not be assembled, so none of them will run.");
159
+ notes.push(` ${messageOf(error)}`);
160
+ return { hosts: [], notes };
161
+ }
162
+
163
+ const taken = new Set(options.workers.map((worker) => worker.name));
164
+ const hosts: HostWorker[] = [];
165
+ for (const spec of HOST_WORKERS) {
166
+ const owner = composed.get(spec.capability);
167
+ if (!owner) continue;
168
+ if (taken.has(spec.capability)) {
169
+ throw new ValidationError({
170
+ message: `A Worker in apps/ is named "${spec.capability}", which is also the ${spec.capability} capability's host.`,
171
+ action: `Rename that Worker with pithy worker add, or move it, so ${spec.capability.toUpperCase()}_ORIGIN names one process.`,
172
+ detail: "Sibling addresses are keyed by worker name; two workers sharing one would publish one address.",
173
+ });
174
+ }
175
+ hosts.push({
176
+ capability: spec.capability,
177
+ spec,
178
+ composed: owner.capability,
179
+ sourceDir: owner.dir,
180
+ worker: {
181
+ name: spec.capability,
182
+ dir: hostWorkerDir(options.projectDir, spec.capability),
183
+ hasWrangler: true,
184
+ dev: hostDev(),
185
+ },
186
+ });
187
+ }
188
+
189
+ return { hosts, notes };
190
+ }
191
+
192
+ /** Options for {@link materializeHostConfigs}. */
193
+ export interface MaterializeHostConfigsOptions {
194
+ projectDir: string;
195
+ /** The project name — the leading segment of every name a resolution derives. Never guessed. */
196
+ project: string;
197
+ /** The app Worker's local origin, which callback links in a locally sent message are built against. */
198
+ baseUrl: string;
199
+ hosts: readonly HostWorker[];
200
+ /** No message may leave this machine — the delivery preflight's verdict, passed to every resolver. */
201
+ simulateDelivery?: boolean;
202
+ /** Seam: read a capability's committed template. Defaults to the file beside its worker entry. */
203
+ readTemplate?: (entry: string) => Promise<WorkflowHostTemplate>;
204
+ }
205
+
206
+ /**
207
+ * What materialization produced: the lines to say, and the hosts that have no config on disk.
208
+ *
209
+ * The second list is not decoration. A host whose resolution threw has no directory — `mkdir` runs on
210
+ * the write path and never got there — so spawning `wrangler dev` in it fails with ENOENT, the
211
+ * orchestrator's spawn-error handler tears the whole session down, and every Worker that was running
212
+ * fine dies for one capability nobody could resolve. The note already said "it will not run"; this is
213
+ * what makes that sentence true.
214
+ */
215
+ export interface HostMaterialization {
216
+ /** Non-fatal lines for the terminal, in the order they happened. */
217
+ notes: string[];
218
+ /** The capability names whose host has no config, and which therefore must not be started. */
219
+ failed: string[];
220
+ }
221
+
222
+ /**
223
+ * Resolve and write each host's local `wrangler.jsonc`. Returns non-fatal notes and the hosts that got
224
+ * none: a capability whose package will not load is named and dropped, because one unresolvable host is
225
+ * not a reason to refuse to run the Workers that are fine — nor to start one in a directory that is
226
+ * not there.
227
+ */
228
+ export async function materializeHostConfigs(options: MaterializeHostConfigsOptions): Promise<HostMaterialization> {
229
+ const readTemplate = options.readTemplate ?? readHostTemplateDefault;
230
+ const notes: string[] = [];
231
+ const failed: string[] = [];
232
+
233
+ for (const host of options.hosts) {
234
+ try {
235
+ const template = await readTemplate(host.spec.entry);
236
+ const resolved = await host.spec.resolve(template, {
237
+ project: options.project,
238
+ env: LOCAL_ENVIRONMENT,
239
+ baseUrl: options.baseUrl,
240
+ databaseId: (binding) => binding,
241
+ capability: host.composed,
242
+ simulateDelivery: options.simulateDelivery,
243
+ });
244
+ const config = forLocalDev(resolved, host.spec.entry);
245
+ const path = join(host.worker.dir, "wrangler.jsonc");
246
+ await mkdir(dirname(path), { recursive: true });
247
+ await writeFileAtomic(path, `${JSON.stringify(config, null, 2)}\n`);
248
+ } catch (error) {
249
+ notes.push(`${host.capability}: its host worker could not be resolved, so it will not run.`);
250
+ notes.push(` ${messageOf(error)}`);
251
+ failed.push(host.capability);
252
+ }
253
+ }
254
+
255
+ return { notes, failed };
256
+ }
257
+
258
+ /** The two edits that are true of every host locally, applied after the capability's own resolver. */
259
+ function forLocalDev(resolved: WorkflowHostTemplate, entry: string): WorkflowHostTemplate {
260
+ const config: WorkflowHostTemplate = { ...resolved };
261
+ if (!isAbsolute(config.main)) config.main = join(dirname(hostTemplatePath(entry)), config.main);
262
+ config.secrets_store_secrets = undefined;
263
+ return config;
264
+ }
265
+
266
+ /** Whether a capability owns a host Worker at all — the question `pithy doctor` and `migrate` both ask. */
267
+ export function capabilityHostsWorkflows(capability: string): boolean {
268
+ return hostWorkerFor(capability) !== undefined;
269
+ }
270
+
271
+ /**
272
+ * What this project would put on the wire from the developer's machine, asked of every host rather
273
+ * than of the one capability that answers. `pithy dev`'s delivery preflight reads this, so the dev
274
+ * command branches on no capability — a tenth host that sends something implements `delivery` and is
275
+ * preflighted with no change here or there.
276
+ *
277
+ * A host whose package will not load answers nothing rather than failing the session: the
278
+ * materialization that follows names that same failure, and once is enough.
279
+ */
280
+ export async function hostDeliveryIdentity(hosts: readonly HostWorker[]): Promise<HostDeliveryIdentity | undefined> {
281
+ for (const host of hosts) {
282
+ try {
283
+ const identity = await host.spec.delivery?.(host.composed);
284
+ if (identity) return identity;
285
+ } catch {
286
+ // Named by materializeHostConfigs, which reaches the same package on the same run.
287
+ }
288
+ }
289
+ return undefined;
290
+ }
@@ -0,0 +1,111 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Single-keypress input for the dev supervisor — the smallest thing that can turn `l` into an action.
6
+ *
7
+ * A key has to be read in **raw mode**, because line mode does not deliver a character until Enter, and
8
+ * the whole point of `l` is that it is one keystroke. Raw mode is also the reason this module is
9
+ * careful: it takes the terminal's own handling away, so Ctrl-C stops generating `SIGINT` and becomes a
10
+ * `\x03` byte like any other. A key reader that forgot that would leave `pithy dev` unstoppable.
11
+ *
12
+ * **Non-TTY never enters raw mode, and never listens.** CI, a piped `pithy dev`, and `pithy dev --json`
13
+ * consumed by a script all land there. `setRawMode` on a non-TTY throws, and a `data` listener on stdin
14
+ * keeps the process alive after every worker has exited — a supervisor that will not exit is a worse
15
+ * bug than a keypress that is missing. So the reader answers `active: false` and does nothing at all.
16
+ *
17
+ * One binding is registered today. The shape takes a list so a second is one line, and deliberately
18
+ * offers no default set: `r` to restart and `o` to open the app are obvious neighbors and neither is
19
+ * this issue.
20
+ */
21
+
22
+ /** The `\x03` byte Ctrl-C becomes once raw mode has taken the terminal's own handling away. */
23
+ const ETX = "\x03";
24
+
25
+ /** The slice of `process.stdin` a key reader uses — an interface so a test can assert what it did *not* do. */
26
+ export interface KeyStream {
27
+ isTTY?: boolean;
28
+ setRawMode?: (mode: boolean) => unknown;
29
+ setEncoding: (encoding: string) => unknown;
30
+ resume: () => unknown;
31
+ pause: () => unknown;
32
+ on: (event: "data", listener: (chunk: string) => void) => unknown;
33
+ off: (event: "data", listener: (chunk: string) => void) => unknown;
34
+ }
35
+
36
+ /** One key, and what it does. `key` is a single character, compared literally. */
37
+ export interface KeyBinding {
38
+ /** The character that triggers it. */
39
+ key: string;
40
+ /** What to do. May be async; a rejection goes to `onError`, never to an unhandled rejection. */
41
+ run: () => void | Promise<void>;
42
+ }
43
+
44
+ /** A live key reader. `active` says whether anything is listening at all. */
45
+ export interface KeyReader {
46
+ /** Whether raw mode was entered and bindings are live. `false` on every non-TTY. */
47
+ active: boolean;
48
+ /** Restore the terminal and stop listening. Idempotent — shutdown can be reached more than once. */
49
+ stop: () => void;
50
+ }
51
+
52
+ /** Everything {@link readKeys} needs. `onInterrupt` is not optional: raw mode is what makes it necessary. */
53
+ export interface ReadKeysOptions {
54
+ stdin?: KeyStream;
55
+ bindings: readonly KeyBinding[];
56
+ /** What Ctrl-C means now that the terminal no longer raises `SIGINT` for it. */
57
+ onInterrupt: () => void;
58
+ /** Where a binding's failure is reported. Without one a rejection is swallowed rather than thrown. */
59
+ onError?: (error: unknown) => void;
60
+ }
61
+
62
+ /** A reader that never touched the terminal — what every non-TTY gets. */
63
+ const inert: KeyReader = { active: false, stop: () => {} };
64
+
65
+ /**
66
+ * Start reading single keypresses, or answer that there is no terminal to read from.
67
+ *
68
+ * A chunk can carry more than one character (a paste, or a fast repeat), so each is dispatched in turn
69
+ * rather than the chunk being compared as a whole.
70
+ */
71
+ export function readKeys(options: ReadKeysOptions): KeyReader {
72
+ const stdin = options.stdin ?? (process.stdin as unknown as KeyStream);
73
+ const setRawMode = stdin.setRawMode;
74
+ if (!stdin.isTTY || typeof setRawMode !== "function") return inert;
75
+
76
+ const onError = options.onError ?? (() => {});
77
+ const bindings = new Map(options.bindings.map((binding) => [binding.key, binding.run]));
78
+
79
+ const onData = (chunk: string): void => {
80
+ for (const character of chunk) {
81
+ if (character === ETX) {
82
+ options.onInterrupt();
83
+ return;
84
+ }
85
+ const run = bindings.get(character);
86
+ if (!run) continue;
87
+ try {
88
+ void Promise.resolve(run()).catch(onError);
89
+ } catch (error) {
90
+ onError(error);
91
+ }
92
+ }
93
+ };
94
+
95
+ setRawMode.call(stdin, true);
96
+ stdin.setEncoding("utf8");
97
+ stdin.resume();
98
+ stdin.on("data", onData);
99
+
100
+ let stopped = false;
101
+ return {
102
+ active: true,
103
+ stop: () => {
104
+ if (stopped) return;
105
+ stopped = true;
106
+ stdin.off("data", onData);
107
+ setRawMode.call(stdin, false);
108
+ stdin.pause();
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching the ANSI ESC (\x1b) is required to strip color codes.
5
+ const ANSI_RX = /\x1b\[[0-9;]*m/g;
6
+
7
+ /** Strip ANSI color codes — the terminal keeps color, the `logs/dev.log` file stays plain text. */
8
+ export function stripAnsi(text: string): string {
9
+ return text.replace(ANSI_RX, "");
10
+ }
11
+
12
+ /** Normalize CRLF and bare CR to LF — wrangler's progress spinner uses bare `\r`, one frame per line. */
13
+ export function normalizeNewlines(text: string): string {
14
+ return text.replace(/\r\n?/g, "\n");
15
+ }
16
+
17
+ /**
18
+ * A stateful splitter that buffers stream chunks and emits one complete line at a time (newlines
19
+ * normalized first). `flush()` emits any trailing partial line when the stream ends. A stream yields
20
+ * arbitrary chunk boundaries, so line assembly must span chunks — this is that assembler.
21
+ */
22
+ export function createLineSplitter(onLine: (line: string) => void): {
23
+ push: (chunk: string) => void;
24
+ flush: () => void;
25
+ } {
26
+ let buffer = "";
27
+ return {
28
+ push(chunk: string) {
29
+ buffer = normalizeNewlines(buffer + chunk);
30
+ for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
31
+ onLine(buffer.slice(0, nl));
32
+ buffer = buffer.slice(nl + 1);
33
+ }
34
+ },
35
+ flush() {
36
+ if (buffer.length > 0) {
37
+ onLine(buffer);
38
+ buffer = "";
39
+ }
40
+ },
41
+ };
42
+ }
43
+
44
+ /** What {@link teeStream} does with each assembled line. */
45
+ export interface TeeSinks {
46
+ /** The prefixed, colorized line for the terminal (`[name] …`). */
47
+ terminal: (line: string) => void;
48
+ /** The prefixed, ANSI-stripped line for `logs/dev.log`. */
49
+ log: (line: string) => void;
50
+ /** The raw line, for ready-signal matching. */
51
+ line: (line: string) => void;
52
+ }
53
+
54
+ /** A minimal readable stream — a real `child.stdout`, or a fake `EventEmitter`/`PassThrough` in tests. */
55
+ export interface DataStream {
56
+ on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
57
+ on(event: "end" | "close", listener: () => void): unknown;
58
+ }
59
+
60
+ /**
61
+ * Tee one child stream to three sinks: a colorized `[label] line` to the terminal, an ANSI-stripped
62
+ * `[label] line` to the log file, and the raw line to ready-signal matching. CR-normalized and line-split
63
+ * so wrangler's spinner and partial chunks each land as clean lines. Resolves when the stream ends.
64
+ */
65
+ export function teeStream(args: {
66
+ stream: DataStream;
67
+ label: string;
68
+ paint: (text: string) => string;
69
+ sinks: TeeSinks;
70
+ }): Promise<void> {
71
+ const { stream, label, paint, sinks } = args;
72
+ const prefix = paint(`[${label}]`);
73
+ const splitter = createLineSplitter((raw) => {
74
+ sinks.terminal(`${prefix} ${raw}`);
75
+ sinks.log(`[${label}] ${stripAnsi(raw)}`);
76
+ sinks.line(raw);
77
+ });
78
+ return new Promise((resolve) => {
79
+ stream.on("data", (chunk) => splitter.push(typeof chunk === "string" ? chunk : chunk.toString("utf8")));
80
+ const done = () => {
81
+ splitter.flush();
82
+ resolve();
83
+ };
84
+ stream.on("end", done);
85
+ stream.on("close", done);
86
+ });
87
+ }
@@ -0,0 +1,75 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+
7
+ /**
8
+ * Open a URL in whatever browser the machine already prefers.
9
+ *
10
+ * **The default browser, and nothing cleverer.** No browser automation, no CDP, no launching a
11
+ * controlled profile — because the thing being opened sets a cookie, and a cookie is only useful in the
12
+ * browser the developer is actually looking at. Handing the OS a URL is also the only approach that
13
+ * works from a second profile or an incognito window, which is precisely the case that made pasting a
14
+ * cookie tedious in the first place.
15
+ *
16
+ * Detached and unref'd: the browser is not a child of the dev session. `pithy dev` exiting must not
17
+ * close a window someone is reading, and a browser that stays open must not keep the supervisor alive.
18
+ */
19
+
20
+ /** The spawn seam — narrow to what this module drives, so a test asserts the argv rather than a browser. */
21
+ export type OpenSpawn = (command: string, args: string[], options: { detached: boolean; stdio: "ignore" }) => OpenChild;
22
+
23
+ /** The two events that settle an open: the child started, or it never did. */
24
+ export interface OpenChild {
25
+ once: (event: "spawn" | "error", listener: (error: Error) => void) => unknown;
26
+ unref: () => unknown;
27
+ }
28
+
29
+ /** Everything {@link openUrl} needs. Both default to the real platform and the real `spawn`. */
30
+ export interface OpenUrlOptions {
31
+ platform?: NodeJS.Platform;
32
+ spawn?: OpenSpawn;
33
+ }
34
+
35
+ /**
36
+ * The opener for one platform. macOS and Windows ship their own; everything else is `xdg-open`, which
37
+ * is the freedesktop standard and present on any Linux with a desktop session.
38
+ *
39
+ * The empty string on Windows is not noise: `start` reads its first quoted argument as the *window
40
+ * title*, so a URL passed without it is consumed as a title and nothing opens.
41
+ */
42
+ export function openCommand(url: string, platform: NodeJS.Platform): { command: string; args: string[] } {
43
+ if (platform === "darwin") return { command: "open", args: [url] };
44
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
45
+ return { command: "xdg-open", args: [url] };
46
+ }
47
+
48
+ /**
49
+ * Hand the URL to the platform opener.
50
+ *
51
+ * Settles on `spawn` or `error` — the child started, or it never did — and **not** on anything about
52
+ * what the browser then did. There is no signal for "a page rendered", and waiting for one would hang
53
+ * the supervisor. A spawn failure (no `xdg-open` on a headless box, most often) becomes a refusal
54
+ * carrying the URL, so the answer to "it did not open" is a line the developer can click.
55
+ */
56
+ export function openUrl(url: string, options: OpenUrlOptions = {}): Promise<void> {
57
+ const platform = options.platform ?? process.platform;
58
+ const spawnChild = options.spawn ?? ((command, args, opts) => spawn(command, args, opts));
59
+ const { command, args } = openCommand(url, platform);
60
+
61
+ return new Promise<void>((resolve, reject) => {
62
+ const child = spawnChild(command, args, { detached: true, stdio: "ignore" });
63
+ child.once("spawn", () => resolve());
64
+ child.once("error", (error) => {
65
+ reject(
66
+ new InternalError({
67
+ message: "Could not open a browser.",
68
+ action: `Open ${url} yourself.`,
69
+ detail: `${command} failed: ${error.message}`,
70
+ }),
71
+ );
72
+ });
73
+ child.unref();
74
+ });
75
+ }