@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,174 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { renderTerminal } from "@pithy-sh/core/src/error/terminal";
6
+ import { resolveCloudflare } from "../cloudflare/config";
7
+ import { projectCloudflareAccount } from "../project/config";
8
+
9
+ /**
10
+ * **Real mail from a laptop, and what happens when it cannot be.**
11
+ *
12
+ * `pithy dev` runs each composed capability's host Worker, and email's host carries the one binding in
13
+ * the kit that puts a message on the wire. `remote: true` on that binding runs the Worker locally and
14
+ * delivers through Cloudflare Email Service for real — the same pipeline, the same DKIM, the same
15
+ * delivery logs as production — which is what makes a magic link triggered from localhost actually
16
+ * arrive (pithy-sh/pithy#410).
17
+ *
18
+ * That depends on two things the kit does not own: a Cloudflare login `wrangler dev` can use, and a
19
+ * sending domain already onboarded onto Email Service. The requirement is not that both always hold.
20
+ * It is that **no path from here ends in silence** — because silence is the defect this whole issue is
21
+ * about, and a developer waiting on an inbox is the worst possible place to discover a missing login.
22
+ *
23
+ * So there are two checks, and they are deliberately different in kind:
24
+ *
25
+ * - {@link deliveryPreflight} runs **before anything spawns**, costs nothing, and *decides*. A check
26
+ * that runs first is worth more than a message that arrives second: where it can already see that
27
+ * real delivery is impossible, the host is resolved with the simulator instead and the banner says
28
+ * so once. The session is correct either way.
29
+ * - {@link deliveryFailureNote} runs over the host's own output and *reports*. The preflight is cheap,
30
+ * which is another way of saying it is not the guarantee: a remote binding is established when the
31
+ * Worker starts, and a domain that is not onboarded most likely fails there — or at the first send.
32
+ * Either shape is caught where it appears and rendered as a `PithyError` with the action that fixes
33
+ * it, rather than scrolling past as somebody else's stack trace.
34
+ *
35
+ * Neither path kills the session. `pithy dev` supervises Workers; a message that did not send is a
36
+ * reason for a sentence, not for tearing down every process a developer is working in.
37
+ */
38
+
39
+ /**
40
+ * Whether Cloudflare credentials resolve at all — the cheap half of the delivery preflight.
41
+ *
42
+ * No network call and no account probe: it reads the file this project's own account selection points
43
+ * at, overlaid with the environment, exactly as every other command does. That is enough to catch the
44
+ * state a developer most often starts in, at none of the cost of asking Cloudflare.
45
+ *
46
+ * Here rather than in the dev command because `pithy dev` and `pithy doctor` both ask it, and they must
47
+ * not come to two answers about one machine.
48
+ */
49
+ export async function hasCloudflareLogin(projectDir: string, env: NodeJS.ProcessEnv): Promise<boolean> {
50
+ try {
51
+ const vars = resolveCloudflare({ account: await projectCloudflareAccount(projectDir), env }).vars;
52
+ return Boolean(vars.CLOUDFLARE_ACCOUNT_ID && vars.CLOUDFLARE_API_TOKEN);
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /** What the preflight was asked to check, and what it had to check with. */
59
+ export interface DeliveryPreflightOptions {
60
+ /** Whether the project composes email at all. Nothing to check when it does not. */
61
+ composed: boolean;
62
+ /** The delivery mode the adopter's config selected — `simulator` is a deliberate choice, not a failure. */
63
+ requested: "remote" | "simulator";
64
+ /** The from address the capability sends as; its domain is what must be onboarded. */
65
+ fromAddress?: string;
66
+ /** Whether Cloudflare credentials resolved at all. `false` means `wrangler dev` has no login to use. */
67
+ hasCloudflareLogin: boolean;
68
+ }
69
+
70
+ /** The preflight's answer: what this session will do about delivery, and the lines that say so. */
71
+ export interface DeliveryPreflight {
72
+ /** Whether the email host is resolved for real delivery. `false` selects the local simulator. */
73
+ live: boolean;
74
+ /** Terminal lines — a problem and its action, or the one line that states a deliberate choice. */
75
+ lines: string[];
76
+ }
77
+
78
+ /** Domains that cannot be onboarded onto Email Service, so a from address on one can never deliver. */
79
+ const UNDELIVERABLE_DOMAINS = new Set(["example.com", "example.org", "example.net", "localhost", "test", "invalid"]);
80
+
81
+ /** The domain half of an address, lowercased, or `undefined` when the address has no usable one. */
82
+ function domainOf(address: string | undefined): string | undefined {
83
+ const at = address?.lastIndexOf("@") ?? -1;
84
+ if (at < 0 || address === undefined) return undefined;
85
+ const domain = address
86
+ .slice(at + 1)
87
+ .trim()
88
+ .toLowerCase();
89
+ return domain === "" ? undefined : domain;
90
+ }
91
+
92
+ /**
93
+ * Decide, before anything spawns, whether this session sends real mail — and say why when it does not.
94
+ *
95
+ * Cheap by construction: no network call, no account lookup. It reads the credentials that already
96
+ * resolved and the address the capability is configured to send as. That catches the two states a
97
+ * developer most often starts a session in — no Cloudflare login at all, and a placeholder from
98
+ * address nobody has replaced — which is most of the value, at none of the cost of asking Cloudflare.
99
+ */
100
+ export function deliveryPreflight(options: DeliveryPreflightOptions): DeliveryPreflight {
101
+ if (!options.composed) return { live: false, lines: [] };
102
+
103
+ if (options.requested === "simulator") {
104
+ return {
105
+ live: false,
106
+ lines: ["Email: the simulator, by config. Messages are logged and written to disk, never sent."],
107
+ };
108
+ }
109
+
110
+ if (!options.hasCloudflareLogin) {
111
+ return {
112
+ live: false,
113
+ lines: [
114
+ "Email: no Cloudflare credentials, so real delivery is not possible here — using the simulator.",
115
+ " run: pithy init, or set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN",
116
+ ],
117
+ };
118
+ }
119
+
120
+ const domain = domainOf(options.fromAddress);
121
+ if (domain === undefined || UNDELIVERABLE_DOMAINS.has(domain)) {
122
+ return {
123
+ live: false,
124
+ lines: [
125
+ `Email: ${options.fromAddress ?? "no from address"} cannot be onboarded onto Email Service — using the simulator.`,
126
+ " set email({ fromAddress }) to an address on a domain you have onboarded.",
127
+ ],
128
+ };
129
+ }
130
+
131
+ return { live: true, lines: [`Email: sending for real from ${options.fromAddress}.`] };
132
+ }
133
+
134
+ /**
135
+ * What the host says when a remote `send_email` binding will not stand up, or a send is rejected.
136
+ *
137
+ * Matched narrowly and on purpose. These are wrangler's and Cloudflare's words, not ours, so this is
138
+ * pattern matching over somebody else's output and the failure mode of being too clever is a false
139
+ * accusation on an unrelated line. Two shapes only — a binding that could not be established, and a
140
+ * send refused for an address or domain that is not onboarded — and anything else falls through to
141
+ * the ordinary tee'd output, where it is at least visible.
142
+ */
143
+ const DELIVERY_FAILURES: readonly { pattern: RegExp; message: string; action: string }[] = [
144
+ {
145
+ pattern: /remote binding.*(send_email|EMAIL)|(send_email|EMAIL).*remote binding/i,
146
+ message: "The email host's send binding could not be established, so nothing will be delivered.",
147
+ action: 'Check the Cloudflare login wrangler dev uses, or set email({ devDelivery: "simulator" }).',
148
+ },
149
+ {
150
+ pattern:
151
+ /(sender|from address|domain).*(not (a )?verified|unverified|not onboarded)|(not (a )?verified|unverified|not onboarded).*(sender|address|domain)/i,
152
+ message: "Cloudflare Email Service refused the sending address — its domain is not onboarded.",
153
+ action: 'Onboard the domain onto Email Service, or set email({ devDelivery: "simulator" }).',
154
+ },
155
+ ];
156
+
157
+ /**
158
+ * A rendered problem + action block for a host output line that reports a delivery failure, or
159
+ * `undefined` for every other line. Rendered through `renderTerminal` so it reads exactly like every
160
+ * other operator-facing failure, rather than being a second error format nobody recognizes.
161
+ */
162
+ export function deliveryFailureNote(line: string): string | undefined {
163
+ const match = DELIVERY_FAILURES.find((failure) => failure.pattern.test(line));
164
+ if (!match) return undefined;
165
+ return renderTerminal(
166
+ new PithyError({
167
+ code: "core/upstream_failed",
168
+ status: 502,
169
+ message: match.message,
170
+ action: match.action,
171
+ detail: line,
172
+ }).payload,
173
+ );
174
+ }
@@ -0,0 +1,155 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { DEV_LOGIN_PATH, DEV_LOGIN_ROUTE, DevLogin } from "@pithy-sh/core/src/seed/devLogin";
7
+
8
+ /**
9
+ * The `pithy dev` end of the dev login: say that there is one, and how to use it — **without ever
10
+ * saying what it is**.
11
+ *
12
+ * The banner is still where this belongs, because it is the only place a developer reliably looks and a
13
+ * seeded session nobody discovers has removed no friction. What changed is what the banner is allowed to
14
+ * carry. It used to hand over the credential itself:
15
+ *
16
+ * ```
17
+ * document.cookie = "better-auth.session_token=…; path=/; max-age=31535918"
18
+ * ```
19
+ *
20
+ * A working session cookie, rendered as text, on every `pithy dev`. Terminals scroll back, output gets
21
+ * tee'd and piped, screenshots get pasted into issues — a value printed once is a value at rest in
22
+ * places nobody chose. `core/src/seed/seed.ts` already treats the login artifact as sensitive for
23
+ * exactly this reason, so printing it was the one place the rule was suspended by design.
24
+ *
25
+ * Now the credential travels over HTTP, from the Worker to the browser, and the terminal carries a
26
+ * keypress and a URL. Neither is a secret: {@link DEV_LOGIN_ROUTE} is registered only in a `dev`
27
+ * composition that is not CI, and it hands out only what `pithy seed` already minted on this machine.
28
+ *
29
+ * **A cookie value must never appear in a string this module returns.** That is asserted directly, over
30
+ * every line every function here can produce, rather than trusted to review.
31
+ */
32
+
33
+ /** A started worker the dev-login route can be opened on — one that composes auth. */
34
+ export interface DevLoginTarget {
35
+ /** The worker's name, as `pithy dev` labels it. */
36
+ name: string;
37
+ /** Its localhost origin, from the pinned port. */
38
+ origin: string;
39
+ }
40
+
41
+ /** What the banner needs to know about the session it is describing. */
42
+ export interface DevLoginBanner {
43
+ /** Whether a keypress can be offered — a TTY that is not being piped, and not `--json`. */
44
+ interactive: boolean;
45
+ /** The started workers that carry the route. Empty is a real answer, and gets its own sentence. */
46
+ targets: readonly DevLoginTarget[];
47
+ /**
48
+ * Whether this run is under CI, where the capability refuses to register the route at all.
49
+ *
50
+ * The keypress follows the route. Offering `l` here would be offering a 404, and it is the *only*
51
+ * refusal `pithy dev` can see coming: every other one is about what is running.
52
+ */
53
+ ci: boolean;
54
+ }
55
+
56
+ /** The one sentence for a run under CI. Stated once, because the banner and the keypress both say it. */
57
+ const CI_REFUSAL = "the dev-login route is not registered under CI.";
58
+
59
+ /** What pressing `l` should do: open this URL, and say these lines. Either half may be empty. */
60
+ export interface DevLoginKeyAction {
61
+ /** The URL to open, or `undefined` when there is nothing to open — never a URL that would 404. */
62
+ url?: string;
63
+ /** What to print. A refusal always says what to do about it; an open says what it is opening. */
64
+ lines: string[];
65
+ }
66
+
67
+ /** Read the seeded dev login, or `undefined` when there is none. Validated — an unreadable file is no login. */
68
+ export async function readDevLogin(projectDir: string): Promise<DevLogin | undefined> {
69
+ try {
70
+ const parsed = DevLogin.safeParse(JSON.parse(await readFile(join(projectDir, DEV_LOGIN_PATH), "utf8")));
71
+ return parsed.success ? parsed.data : undefined;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /** Where a worker serves the dev login. The route is core's, so both ends spell it the same way once. */
78
+ export function devLoginUrl(origin: string): string {
79
+ return `${origin}${DEV_LOGIN_ROUTE}`;
80
+ }
81
+
82
+ /**
83
+ * A usable login, or `undefined`.
84
+ *
85
+ * An expired cookie is worse than no cookie: it looks like a way in, fails silently in the browser, and
86
+ * sends someone hunting for a bug in auth. The banner therefore treats expiry as absence; the keypress
87
+ * does not, because someone who pressed `l` asked a question and deserves the reason.
88
+ */
89
+ function usable(login: DevLogin | undefined, now: Date): DevLogin | undefined {
90
+ if (!login) return undefined;
91
+ return login.expiresAt.getTime() > now.getTime() ? login : undefined;
92
+ }
93
+
94
+ /** One indented `name: url` line per target — the shape both the banner and the keypress list choices in. */
95
+ function choices(targets: readonly DevLoginTarget[]): string[] {
96
+ return targets.map((target) => ` ${target.name}: ${devLoginUrl(target.origin)}`);
97
+ }
98
+
99
+ /**
100
+ * The banner lines for a seeded dev login — empty when there is none, and empty when it has expired.
101
+ *
102
+ * Four shapes, because the honest sentence differs: a keypress where there is a keypress and one target,
103
+ * a URL where there is not, the choices where more than one worker composes auth, and a plain statement
104
+ * where nothing running carries the route at all. Nothing here guesses.
105
+ */
106
+ export function devLoginLines(login: DevLogin | undefined, now: Date, banner: DevLoginBanner): string[] {
107
+ const live = usable(login, now);
108
+ if (!live) return [];
109
+ if (banner.ci) return [`Dev login: ${live.email} — ${CI_REFUSAL}`];
110
+ const targets = banner.targets;
111
+
112
+ if (targets.length === 0) {
113
+ return [`Dev login: ${live.email} — no running worker composes auth, so there is nothing to open.`];
114
+ }
115
+ if (banner.interactive) {
116
+ const verb = targets.length === 1 ? "open a signed-in browser" : "choose a worker and open a signed-in browser";
117
+ return [`Dev login: ${live.email} — press l to ${verb}.`];
118
+ }
119
+ const first = targets[0];
120
+ if (targets.length === 1 && first) {
121
+ return [`Dev login: ${live.email} — open ${devLoginUrl(first.origin)} to sign in.`];
122
+ }
123
+ return [`Dev login: ${live.email} — open one of these to sign in.`, ...choices(targets)];
124
+ }
125
+
126
+ /**
127
+ * What `l` does, decided without touching the terminal or the network so it can be tested as a value.
128
+ *
129
+ * Every refusal names the command that fixes it and opens nothing. A browser sent to a route that 404s
130
+ * is worse than a sentence: it looks like the feature is broken rather than like the session is missing.
131
+ */
132
+ export function devLoginKeyAction(
133
+ login: DevLogin | undefined,
134
+ now: Date,
135
+ targets: readonly DevLoginTarget[],
136
+ ci = false,
137
+ ): DevLoginKeyAction {
138
+ if (!login) return { lines: ["No dev login is seeded. Run pithy seed, then press l again."] };
139
+ if (!usable(login, now)) {
140
+ return { lines: ["The seeded dev login has expired. Run pithy seed to mint a fresh one."] };
141
+ }
142
+ // Before the targets, because this refusal is about the route rather than about what is running: the
143
+ // workers below all compose auth and none of them mounted it.
144
+ if (ci) return { lines: [`Not opening — ${CI_REFUSAL}`] };
145
+ if (targets.length === 0) return { lines: ["No running worker composes auth, so there is nothing to open."] };
146
+
147
+ const first = targets[0];
148
+ if (targets.length === 1 && first) {
149
+ const url = devLoginUrl(first.origin);
150
+ return { url, lines: [`Opening ${url} as ${login.email}.`] };
151
+ }
152
+ // More than one worker carries the route, and they are separate origins — a cookie set on one signs
153
+ // nobody into the other. There is no defensible guess, so the choice is printed.
154
+ return { lines: ["More than one worker composes auth. Open the one you want:", ...choices(targets)] };
155
+ }
@@ -0,0 +1,91 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { allCapabilities, loadWorkerConfig } from "../project/config";
5
+ import { parseWorkerManifest } from "../project/workerManifest";
6
+ import type { DevLoginTarget } from "./devLogin";
7
+
8
+ /**
9
+ * Which running worker `l` opens, in a project that runs more than one.
10
+ *
11
+ * **The rule is "the worker that carries the route", not "the worker with the UI"** — and the reason is
12
+ * the cookie, not preference. `Set-Cookie` is scoped to the origin that sent it, and every worker in a
13
+ * dev session is a different `localhost:<port>`. Opening a worker that does not compose auth would 404
14
+ * on {@link DEV_LOGIN_ROUTE}; opening one that does, and then browsing to a *different* worker, leaves
15
+ * the browser signed out on the second origin. So the candidate set is exactly the workers that compose
16
+ * auth, which is a fact read off each worker's own `pithy.config.ts` rather than guessed.
17
+ *
18
+ * **The UI block breaks a tie, and only a tie.** `pithy ui add` records `ui` in `pithy.worker.jsonc`,
19
+ * and in the ordinary Pithy project that block sits on the same worker that serves the API — one origin,
20
+ * one cookie, one thing to look at. Where two workers both compose auth and one of them is the one with
21
+ * a front end, that is the one a developer meant. Where the signal does not decide — neither carries a
22
+ * UI, or both do — nothing is narrowed and the caller prints the choices. A silent guess between two
23
+ * origins is the failure this avoids: it looks like the feature worked, and the browser is signed in to
24
+ * the wrong one.
25
+ *
26
+ * Both probes are non-fatal. A worker whose `pithy.config.ts` will not import composes nothing as far as
27
+ * this is concerned; wrangler reports that file's failure, loudly, and inventing a second complaint here
28
+ * would only bury it.
29
+ */
30
+
31
+ /** A started worker, as this module needs it: its label, its directory, and where it answers. */
32
+ export interface StartedWorkerDir {
33
+ name: string;
34
+ /** The `apps/<name>/` directory holding its `pithy.config.ts` and `pithy.worker.jsonc`. */
35
+ dir: string;
36
+ origin: string;
37
+ }
38
+
39
+ /** Everything {@link devLoginTargets} needs. Both probes default to reading the worker's own files. */
40
+ export interface DevLoginTargetOptions {
41
+ started: readonly StartedWorkerDir[];
42
+ /** Does this worker compose the auth capability, and therefore carry the route? */
43
+ composesAuth?: (dir: string) => Promise<boolean>;
44
+ /** Does this worker carry a front end (`ui` in its `pithy.worker.jsonc`)? Breaks a tie, nothing more. */
45
+ hasUi?: (dir: string) => Promise<boolean>;
46
+ }
47
+
48
+ /** Whether a worker's own config composes auth. A config that will not load composes nothing. */
49
+ async function composesAuthDefault(dir: string): Promise<boolean> {
50
+ try {
51
+ return allCapabilities(await loadWorkerConfig(dir)).some((capability) => capability.name === "auth");
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /** Whether a worker carries a front end. The `ui` block's presence *is* the signal (see `ui/workerUi.ts`). */
58
+ async function hasUiDefault(dir: string): Promise<boolean> {
59
+ try {
60
+ return (await parseWorkerManifest(dir))?.ui !== undefined;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /** Run a predicate over every started worker, keeping the started order. */
67
+ async function keep(
68
+ workers: readonly StartedWorkerDir[],
69
+ predicate: (dir: string) => Promise<boolean>,
70
+ ): Promise<StartedWorkerDir[]> {
71
+ const verdicts = await Promise.all(
72
+ workers.map(async (worker) => {
73
+ try {
74
+ return await predicate(worker.dir);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }),
79
+ );
80
+ return workers.filter((_worker, index) => verdicts[index] === true);
81
+ }
82
+
83
+ /** The workers `l` may open, in started order — one to open, several to choose from, or none. */
84
+ export async function devLoginTargets(options: DevLoginTargetOptions): Promise<DevLoginTarget[]> {
85
+ const carriers = await keep(options.started, options.composesAuth ?? composesAuthDefault);
86
+ if (carriers.length <= 1) return carriers.map(({ name, origin }) => ({ name, origin }));
87
+
88
+ const withUi = await keep(carriers, options.hasUi ?? hasUiDefault);
89
+ const chosen = withUi.length === 1 ? withUi : carriers;
90
+ return chosen.map(({ name, origin }) => ({ name, origin }));
91
+ }
package/src/dev/env.ts ADDED
@@ -0,0 +1,206 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CI_ENV, isContinuousIntegration } from "@pithy-sh/core/src/env/ci";
5
+ import { envStem } from "@pithy-sh/core/src/env/stem";
6
+ import { WORKER_ORIGIN_VAR } from "@pithy-sh/core/src/worker/identity";
7
+ import type { DevConfig } from "../feature/devConfig";
8
+ import { DEV_PORT_TOKEN } from "../project/workerManifest";
9
+ import type { WorkerTarget } from "../project/workers";
10
+
11
+ /**
12
+ * Build the environment every child inherits: the parent env plus, for **every** worker in the feature's
13
+ * dev config, `<STEM>_PORT` and `<STEM>_ORIGIN`. That is how workers reach each other — each sibling's
14
+ * localhost address is known ahead of time and injected, so a worker calls its peers directly instead of
15
+ * relying on wrangler's flaky cross-`wrangler dev` service registry.
16
+ */
17
+ export function buildWorkerEnv(config: DevConfig, base: NodeJS.ProcessEnv = process.env): Record<string, string> {
18
+ const env: Record<string, string> = {};
19
+ for (const [key, value] of Object.entries(base)) {
20
+ if (value !== undefined) env[key] = value;
21
+ }
22
+ for (const [name, worker] of Object.entries(config.workers)) {
23
+ const stem = envStem(name);
24
+ env[`${stem}_PORT`] = String(worker.port);
25
+ env[`${stem}_ORIGIN`] = worker.origin;
26
+ }
27
+ return env;
28
+ }
29
+
30
+ /**
31
+ * The origin to hand one worker as its own, or `null` when somebody else owns it.
32
+ *
33
+ * `null` is a capability host: its `BASE_URL` is the *app's* origin, because it holds no public route
34
+ * and a verification link it mails has to arrive back at the app. `materializeHostConfigs` writes that
35
+ * into the host's generated config, so a value from here could only be a second producer of one
36
+ * setting — and the one it would produce is the wrong one.
37
+ *
38
+ * One function rather than a condition repeated at each carrier, so a host cannot be exempt from the
39
+ * argv path and not from the environment path.
40
+ */
41
+ export function ownOriginFor(workerName: string, origin: string, hostNames: ReadonlySet<string>): string | null {
42
+ return hostNames.has(workerName) ? null : origin;
43
+ }
44
+
45
+ /**
46
+ * One child's environment: everything {@link buildWorkerEnv} publishes, plus that child's own origin.
47
+ *
48
+ * Per child, and that is the point. `buildWorkerEnv` is built once and shared, because `<STEM>_ORIGIN`
49
+ * is the same table of *other people's* addresses for everybody. "Where do I answer" is the one fact
50
+ * that differs per child, so it cannot live in the shared object.
51
+ */
52
+ export function childEnvFor(shared: Record<string, string>, ownOrigin: string | null): Record<string, string> {
53
+ if (ownOrigin === null) return shared;
54
+ return { ...shared, [WORKER_ORIGIN_VAR]: ownOrigin };
55
+ }
56
+
57
+ /** Turn `["dev", "--port", "8787"]` into a spawnable `{ command, args }` via the project's package manager. */
58
+ export type WranglerLauncher = (args: string[]) => { command: string; args: string[] };
59
+
60
+ /** A resolved start command for one worker. */
61
+ export interface StartCommand {
62
+ command: string;
63
+ args: string[];
64
+ }
65
+
66
+ /**
67
+ * The command that starts one worker at its pinned `port`.
68
+ *
69
+ * A worker with an explicit `dev.command` (e.g. a Vite frontend with no `wrangler.jsonc`) runs that verbatim
70
+ * — the port is **not** appended; it reaches the process through `<STEM>_PORT` in the env, or through the
71
+ * `{port}` token wherever the manifest placed it on the argv. That token is the one substitution, and it
72
+ * exists because the orchestrator spawns with no shell: `$WEB_PORT` in an argv array is a literal, never an
73
+ * expansion, and a dev server that takes its port as a flag (`vite dev --port {port}`) has nowhere else to
74
+ * read it. A command with no token runs byte-identically. A plain Worker runs `wrangler dev --port <port>
75
+ * --inspector-port 0` (inspector `0` auto-assigns, so multiple workers never collide on the inspector port),
76
+ * resolved through the project's package manager rather than a hardcoded global wrangler.
77
+ *
78
+ * **`--persist-to` is what makes local sharing real.** Every Worker lives in its own `apps/<name>/` and
79
+ * wrangler defaults its local state to the *cwd* it runs in, so each Worker would get its own `.wrangler/`
80
+ * store — and two Workers that deliberately declare the same binding (the way Workers share a database)
81
+ * would silently read and write two different local D1s, with the divergence showing up only as
82
+ * inexplicably missing rows. Pointing every Worker at one project-level store makes local behave the way
83
+ * the deployed environment does.
84
+ */
85
+ export function startCommand(
86
+ worker: WorkerTarget,
87
+ port: number,
88
+ origin: string | null,
89
+ launchWrangler: WranglerLauncher,
90
+ persistTo: string,
91
+ baseEnv: NodeJS.ProcessEnv = process.env,
92
+ hostPorts: Readonly<Record<string, number>> = {},
93
+ ): StartCommand {
94
+ const custom = worker.dev?.command;
95
+ if (custom && custom.length > 0) {
96
+ // A custom command is run verbatim: it is not necessarily wrangler, so no flag is appended. The only
97
+ // edit is `{port}` → the pinned port, in every element that carries it. It also gets no `--var`: the
98
+ // process already inherits the real environment through `buildWorkerEnv`, which is exactly what the
99
+ // forwarding below exists to work around for workerd.
100
+ const resolved = custom.map((part) => part.replaceAll(DEV_PORT_TOKEN, String(port)));
101
+ return { command: resolved[0] as string, args: resolved.slice(1) };
102
+ }
103
+ return launchWrangler([
104
+ "dev",
105
+ "--port",
106
+ String(port),
107
+ "--inspector-port",
108
+ "0",
109
+ "--persist-to",
110
+ persistTo,
111
+ ...ownOriginVarArgs(origin),
112
+ ...ciVarArgs(baseEnv),
113
+ ...hostVarArgs(worker.name, hostPorts),
114
+ ]);
115
+ }
116
+
117
+ /**
118
+ * Tell the Worker where it itself answers, as `BASE_URL`.
119
+ *
120
+ * **The one address a Worker cannot work out and cannot be made to write down.** It cannot derive it
121
+ * from a request: `Host` is caller-controlled, so a Worker that did would take its own identity from
122
+ * whoever called it. And it cannot state it in `wrangler.jsonc`, because a dev port is *allocated* —
123
+ * every checkout reserves its own block, so a literal there is right in the first checkout on a machine
124
+ * and wrong in every other one. `pithy dev` is the only party that knows, and this is where it says so.
125
+ *
126
+ * `--var` beats a `vars` entry in the config, so a project that already wrote a dev `BASE_URL` down is
127
+ * corrected rather than asked to edit anything. Deployed environments never reach here: `applyDomains`
128
+ * generates theirs from the `domains` declaration, and this runs only under `pithy dev`.
129
+ *
130
+ * **Note what this is not.** {@link hostVarArgs} deliberately withholds a host's own `<STEM>_ORIGIN`,
131
+ * because that var names a *dispatch target* and a Worker posting to itself is a request that answers
132
+ * itself forever. `BASE_URL` is the opposite kind of fact — it is an identity, the `iss` a
133
+ * control-plane token carries and the origin a callback link is built against — and withholding it is
134
+ * what made `pithy-sh/dashboard#95`: a second checkout signed tokens as the first one and its own seam
135
+ * denied every call. The two vars look alike and mean opposite things.
136
+ *
137
+ * The origin travels **verbatim** from `.dev.config.json` rather than being rebuilt as
138
+ * `http://localhost:${port}`. The config pins both, and recomposing one from the other is a second
139
+ * producer of a value that is already written down — the same rule `SeedPrepareContext.origin` states.
140
+ *
141
+ * **`null` means somebody else owns this Worker's `BASE_URL`, and today that is a capability host.**
142
+ * A host's is the *app's* origin, not its own: it holds no public route, and a callback link it mails
143
+ * has to arrive back at the app. `materializeHostConfigs` writes that value into the host's generated
144
+ * `wrangler.jsonc`, from the same allocation, so a `--var` here could only be a second producer of one
145
+ * value — and the one it would produce is the wrong one. Overriding it would point every verification
146
+ * link at the email host.
147
+ */
148
+ function ownOriginVarArgs(origin: string | null): string[] {
149
+ if (origin === null) return [];
150
+ // wrangler splits a `--var` at its first colon, so the `http://` in the value survives intact.
151
+ return ["--var", `BASE_URL:${origin}`];
152
+ }
153
+
154
+ /**
155
+ * Forward each capability host's address into the Worker as vars.
156
+ *
157
+ * Same reason as {@link ciVarArgs}, and the same mechanism: `buildWorkerEnv` publishes
158
+ * `<STEM>_ORIGIN` and `<STEM>_PORT` into every child *process*, and the host environment does not
159
+ * cross into workerd. So an app Worker asking for `EMAIL_ORIGIN` — the address core's loopback
160
+ * dispatcher posts a Workflow dispatch to, in place of the cross-script binding a deployed
161
+ * environment has — would read nothing at all. One `--var` per host is what makes the read truthful.
162
+ *
163
+ * Only the hosts, not every sibling. An `apps/*` Worker reaches another over `<STEM>_ORIGIN` in the
164
+ * *process* env today, and widening this to all of them is a change to what every Worker sees rather
165
+ * than the one wire this issue is about (pithy-sh/pithy#410).
166
+ *
167
+ * A host is never handed its own address: it *is* the thing at that origin, and a self-dispatch
168
+ * loop is a request that answers itself forever.
169
+ *
170
+ * **That is a rule about dispatch targets, not about self-knowledge.** A Worker does learn where it
171
+ * itself answers — see {@link ownOriginVarArgs}, which hands it exactly that as `BASE_URL`. The two
172
+ * vars are adjacent, look alike, and mean opposite things: `<STEM>_ORIGIN` is somewhere to send a
173
+ * request, `BASE_URL` is who you are. Reading the paragraph above as "a Worker never learns its own
174
+ * origin" is what left every checkout but the first signing tokens as another one (#462).
175
+ */
176
+ function hostVarArgs(workerName: string, hostPorts: Readonly<Record<string, number>>): string[] {
177
+ const args: string[] = [];
178
+ for (const [name, port] of Object.entries(hostPorts)) {
179
+ if (name === workerName) continue;
180
+ const stem = envStem(name);
181
+ // wrangler splits a `--var` at its first colon, so the `http://` in the value survives intact.
182
+ args.push("--var", `${stem}_ORIGIN:http://localhost:${port}`, "--var", `${stem}_PORT:${port}`);
183
+ }
184
+ return args;
185
+ }
186
+
187
+ /**
188
+ * Forward `CI` into the Worker as a var, when this process is running under one.
189
+ *
190
+ * **The host's environment does not cross into workerd.** With `nodejs_compat`, `process.env` inside a
191
+ * Worker is populated from that script's own `vars` and secrets and nothing else — verified against a
192
+ * real `wrangler dev`, where `Object.keys(process.env)` at module scope is exactly the declared vars. So
193
+ * a capability that refuses to register itself under CI (`@pithy-sh/auth`'s dev-login route is the first)
194
+ * cannot see the `CI=true` that GitHub Actions set in the shell that ran this command. One `--var` is
195
+ * what makes that read truthful for every Worker Pithy starts.
196
+ *
197
+ * Nothing is forwarded off CI, so an ordinary `pithy dev` writes no var and the Worker's `process.env` is
198
+ * byte-identical to what it was. And the forwarding is a convenience, never the security boundary: the
199
+ * capability's environment gate refuses in `staging` and `prod` with no cooperation from anything here.
200
+ */
201
+ function ciVarArgs(env: NodeJS.ProcessEnv): string[] {
202
+ if (!isContinuousIntegration(env)) return [];
203
+ // The value travels verbatim rather than normalized to `true`: "any non-blank value" is the rule at
204
+ // both ends (#218), and rewriting it here would be this file inventing a second one.
205
+ return ["--var", `${CI_ENV}:${env[CI_ENV] ?? ""}`];
206
+ }