@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,265 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { z } from "zod";
7
+ import type { StatePathOptions } from "../notifier/state";
8
+ import { writeFileAtomic } from "../project/atomic";
9
+ import { loadProject, requireProjectName } from "../project/config";
10
+ import { type MergeBase, readMergeBase } from "../project/readOptionalFile";
11
+ import { devPreferencesPath } from "../seed/prepare";
12
+ import { ensureDevSecretsDir } from "./location";
13
+ import { tightenMode } from "./mode";
14
+
15
+ /**
16
+ * The bootstrap half of a generated `.dev.vars`: the values a Worker reads from an **env binding** rather
17
+ * than from a secrets store — the dev master key, a `cf-secrets-store` secret, a Turnstile sitekey.
18
+ *
19
+ * **It lives in `dev.json`, as a second tenant (#154).** `<config>/<project>/dev.json` was already there
20
+ * for dev-login preferences (#131), it is already machine-local, already outside every checkout, and
21
+ * already the file `pithy doctor` names. Inventing a second location for "the machine's dev values" would
22
+ * have been a third convention for the same directory.
23
+ *
24
+ * **Why it has to persist anywhere at all.** `.dev.vars` used to be the store: `pithy add secrets` minted
25
+ * a master key into it and every later run read it back from there. A generated file cannot be its own
26
+ * source of truth — regenerating it would mean reading what the last generation wrote, which is the
27
+ * accumulating file this change exists to end. So the values move one level out, to the place the
28
+ * secrets file already went, and the generated file becomes a pure function of them.
29
+ *
30
+ * **Not a second copy of the secrets file.** `secrets.jsonc` holds application secrets as versioned
31
+ * envelopes, seeded into a Worker's local `SECRETS` store. This holds the flat `KEY=value` set that has
32
+ * to be a binding, because dev has no Secrets Store to read one from. A `d1` secret is never here.
33
+ *
34
+ * Same directory, same 0700, same 0600 as its neighbor — for the same reason: the dev master key is in
35
+ * it, and the umask is not a permission policy for a credential.
36
+ */
37
+
38
+ /** The `dev.json` key this set lives under. Namespaced, because the file has other tenants. */
39
+ export const BOOTSTRAP_VARS_KEY = "vars";
40
+
41
+ /**
42
+ * The bootstrap set as it appears in `dev.json` — a flat map of `.dev.vars` variable names to values.
43
+ *
44
+ * Validated because it is read off disk and hand-editable, and it decides what a Worker's bindings are.
45
+ * A non-string value would reach {@link import("./devVars").encodeDevVarsValue} as an object and be
46
+ * written as `[object Object]`.
47
+ */
48
+ export const BootstrapVars = z
49
+ .record(
50
+ z.string().describe("The `.dev.vars` variable name — wrangler's namespace, so UPPER_SNAKE by convention."),
51
+ z.string().describe("The value the Worker's binding carries. Never logged, never printed, never in --json."),
52
+ )
53
+ .describe("Every value this machine has to hand a Worker through a .dev.vars binding, by variable name.");
54
+
55
+ /** The bootstrap set. Same name as its schema, as every Zod object in this repo is. */
56
+ export type BootstrapVars = z.output<typeof BootstrapVars>;
57
+
58
+ /**
59
+ * The shape this module reads `dev.json` as: its own key, and every other tenant's, preserved.
60
+ *
61
+ * `catchall` rather than a closed object, because a write here must not delete the dev-login preferences
62
+ * sitting beside it. Two tenants in one file is the whole reason the key exists.
63
+ *
64
+ * **Exported so a caller planning a write into this file reads it through one schema (#222).** A
65
+ * planning read goes through `readMergeBase` against this schema, so what it computes is a `dev.json`
66
+ * that was actually read rather than a `{}` a lenient read invented. One schema, because two would be
67
+ * two answers to "what is in that file", and a plan is what somebody deletes a line on the strength of.
68
+ */
69
+ export const DevJson = z
70
+ .object({
71
+ [BOOTSTRAP_VARS_KEY]: BootstrapVars.optional().describe("The bootstrap `.dev.vars` set — this module's tenant."),
72
+ })
73
+ .catchall(z.unknown().describe("Another tenant's key, read and written back untouched."))
74
+ .describe("This machine's per-project dev file, of which the bootstrap vars are one tenant.");
75
+
76
+ /** The whole file, every tenant included. Same name as its schema, as every Zod object in this repo is. */
77
+ export type DevJson = z.output<typeof DevJson>;
78
+
79
+ /** `<config>/<project>/dev.json` for a project root, or `null` when the project has no name to key on. */
80
+ export async function bootstrapVarsPath(projectDir: string, options: StatePathOptions = {}): Promise<string | null> {
81
+ try {
82
+ return devPreferencesPath(requireProjectName(await loadProject(projectDir)), options);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * This project's bootstrap set, or an empty one. **The reporting read of the two in this file.**
90
+ *
91
+ * **Every failure is an empty set, and that is deliberate here where it is a defect elsewhere.** The
92
+ * sibling readers refuse an unreadable credential file because they are about to *rewrite* it, and
93
+ * "absent" would license replacing what they could not read. Nothing is rewritten from this call — the
94
+ * result is merged into a file that is regenerated wholesale — so a `dev.json` that will not parse costs
95
+ * a set of bindings and says so through the Worker's own missing-binding error, rather than stopping
96
+ * `pithy dev` over a hand-edited preferences file. This is the read `pithy dev` starts through.
97
+ *
98
+ * **What that argument is not about (#219).** It is an argument about *this call*, and it was read for a
99
+ * while as if it were about the file: the two writers below reached the same `{}` through
100
+ * {@link readDevJson} and renamed a document built from it over a file with another tenant in it. They
101
+ * refuse now, in every state but "there is no file", and this one still answers `{}` — the split is the
102
+ * fix, not a tightening. It is a type rather than a convention: they take a {@link MergeBase}, which only
103
+ * `readMergeBase` mints, so nothing that reads leniently can be handed to them by accident.
104
+ *
105
+ * So this read deliberately does *not* go through the primitive, and it is written down as such in that
106
+ * module's gate (`../project/readOptionalFile.test.ts`) rather than left to be rediscovered.
107
+ */
108
+ export async function readBootstrapVars(projectDir: string, options: StatePathOptions = {}): Promise<BootstrapVars> {
109
+ const path = await bootstrapVarsPath(projectDir, options);
110
+ if (path === null) return {};
111
+ const source = await readFile(path, "utf8").catch(() => null);
112
+ if (source === null) return {};
113
+ const parsed = DevJson.safeParse(safeJson(source));
114
+ if (!parsed.success) return {};
115
+ return parsed.data[BOOTSTRAP_VARS_KEY] ?? {};
116
+ }
117
+
118
+ /**
119
+ * Merge `values` into this project's bootstrap set and write `dev.json` back. Returns the merged set,
120
+ * so a caller regenerating from it never re-reads what it has just written.
121
+ *
122
+ * **Read-modify-write over a file with other tenants**, so every state but "there is no file" refuses:
123
+ * writing this set over a `dev.json` that could not be read, or is not JSON, or parsed to something that
124
+ * is not a document, or is a record of something else, would silently delete a developer's dev-login
125
+ * preference along with every value this module had. Only `ENOENT` licenses starting from `{}`.
126
+ *
127
+ * A value is never removed here. A name that leaves the registry leaves a line nothing reads; `pithy
128
+ * doctor` is where a value nobody declares gets named, in this file exactly as in its neighbor.
129
+ *
130
+ * **An empty `values` returns before the read, and that is a decision (#222).** The refusal above exists
131
+ * because a merge into an invented `{}` gets renamed over another tenant's keys; a call that writes
132
+ * nothing performs no merge and no rename, so there is nothing for it to protect. It was reached —
133
+ * `writeDevVars({ values: {} })` is the turnstile teardown's regeneration — where refusing turns a
134
+ * regeneration into a failure over a file the run was never going to touch. The split in this module is
135
+ * by *power*, not by file, and a write of nothing has a reader's power: the set it answers is
136
+ * {@link readBootstrapVars}'s, which is the read whose whole argument is that it rewrites nothing.
137
+ */
138
+ export async function writeBootstrapVars(
139
+ projectDir: string,
140
+ values: Record<string, string>,
141
+ options: StatePathOptions = {},
142
+ ): Promise<BootstrapVars> {
143
+ const path = await bootstrapVarsPath(projectDir, options);
144
+ if (path === null) return {};
145
+ if (Object.keys(values).length === 0) return readBootstrapVars(projectDir, options);
146
+ const base = await readDevJson(path);
147
+ const merged: BootstrapVars = { ...(base.document[BOOTSTRAP_VARS_KEY] ?? {}), ...values };
148
+ await ensureDevSecretsDir(base.path);
149
+ await writeFileAtomic(base.path, `${JSON.stringify(withBootstrapVars(base, merged), null, 2)}\n`, {
150
+ mode: 0o600,
151
+ });
152
+ // Unconditionally and after the write, the same as the secrets file's: a `dev.json` an older pithy or an
153
+ // editor created at the umask kept 0644 while holding the dev master key. Narrowing only.
154
+ await tightenMode(base.path);
155
+ return merged;
156
+ }
157
+
158
+ /**
159
+ * The document to write: every tenant's key as it was read, with this module's set replaced.
160
+ *
161
+ * **It takes a {@link MergeBase} and that is the whole point (#219).** Only `readMergeBase` mints one, so
162
+ * {@link readBootstrapVars} — which answers `{}` for a file it cannot make sense of, correctly, because
163
+ * it rewrites nothing — cannot be handed to it. The accident that produced five instances of this defect
164
+ * is a type error here rather than another tenant's key gone at the next rename.
165
+ */
166
+ function withBootstrapVars(base: MergeBase<DevJson>, vars: BootstrapVars): DevJson {
167
+ return { ...base.document, [BOOTSTRAP_VARS_KEY]: vars };
168
+ }
169
+
170
+ /**
171
+ * Take names out of this project's bootstrap set — the teardown half, for a value whose resource no
172
+ * longer exists. Returns the set that remains, so a caller regenerating from it never re-reads.
173
+ *
174
+ * A name that is not there is a no-op, and a project with no `dev.json` is not created one. Removing a
175
+ * value here is what makes the next generation drop its line: the generated file is built from the
176
+ * sources rather than edited, so there is no line to delete anywhere else.
177
+ *
178
+ * **That no-op still reads strictly, and its sibling does not — same rule, different answer (#222).** The
179
+ * early return belongs before the read exactly when the *caller's arguments* settle whether anything is
180
+ * written. {@link writeBootstrapVars} knows that from an empty `values`. This one only learns it from the
181
+ * file, and a file nothing could read supports no claim about what is in it — least of all "that name is
182
+ * not there". So the one case its arguments do settle, an empty `names`, returns before the read, and
183
+ * every other case earns its no-op by reading the document properly or refusing.
184
+ */
185
+ export async function removeBootstrapVars(
186
+ projectDir: string,
187
+ names: readonly string[],
188
+ options: StatePathOptions = {},
189
+ ): Promise<BootstrapVars> {
190
+ const path = await bootstrapVarsPath(projectDir, options);
191
+ if (path === null) return {};
192
+ if (names.length === 0) return readBootstrapVars(projectDir, options);
193
+ const base = await readDevJson(path);
194
+ const current: BootstrapVars = base.document[BOOTSTRAP_VARS_KEY] ?? {};
195
+ const remaining = Object.fromEntries(Object.entries(current).filter(([name]) => !names.includes(name)));
196
+ if (Object.keys(remaining).length === Object.keys(current).length) return current;
197
+ await ensureDevSecretsDir(base.path);
198
+ await writeFileAtomic(base.path, `${JSON.stringify(withBootstrapVars(base, remaining), null, 2)}\n`, {
199
+ mode: 0o600,
200
+ });
201
+ await tightenMode(base.path);
202
+ return remaining;
203
+ }
204
+
205
+ /**
206
+ * The merge base for a write over `dev.json`: every tenant's key intact, known-good, or a refusal.
207
+ *
208
+ * **The writers' read, and the strict half of this file's split.** `{}` for an absent file and for
209
+ * nothing else. Unopenable, not JSON, parsed to something that is not a document, or a document of
210
+ * something else: four refusals, because the two writers below merge into this answer and rename the
211
+ * result over a file with another tenant in it. Answering `{}` for any of the four is a dev-login
212
+ * preference and this module's own set deleted, with the run reporting a clean write.
213
+ *
214
+ * It used to answer `{}` for a non-object (#209), and then for a file that would not parse and for one
215
+ * that failed its schema (#219) — each corrected once someone noticed that the argument for leniency was
216
+ * {@link readBootstrapVars}'s and had been read as if it belonged to the file. The two are different
217
+ * calls with different powers, and now different types: this one hands back a {@link MergeBase}, which
218
+ * nothing else in this repository can produce.
219
+ *
220
+ * The `ENOENT`-only rule and the other three are `readMergeBase`'s (`../project/readOptionalFile.ts`);
221
+ * the words below are this file's, because "cannot update" is what a read-modify-write over somebody
222
+ * else's tenants has to say.
223
+ */
224
+ async function readDevJson(path: string): Promise<MergeBase<DevJson>> {
225
+ return readMergeBase(path, DevJson, {
226
+ unreadable: ({ code, cause }) =>
227
+ new ConflictError(
228
+ {
229
+ message: `Cannot update ${path}: Pithy could not read what is already in it.`,
230
+ action: "Fix the file's permissions, or move it aside, and run the command again.",
231
+ detail: `${code ?? "unknown error"} while reading ${path}`,
232
+ },
233
+ { cause },
234
+ ),
235
+ unparseable: () =>
236
+ new ConflictError({
237
+ message: `Cannot update ${path}: it is there and is not JSON.`,
238
+ action: "Fix the JSON, or move it aside, and run the command again.",
239
+ // Never the parser's own message: it quotes the line it choked on, and the dev master key is one.
240
+ detail: `dev.json at ${path} did not parse, and every tenant's keys would be replaced by a write from it`,
241
+ }),
242
+ notARecord: ({ found }) =>
243
+ new ConflictError({
244
+ message: `Cannot update ${path}: it holds ${found}, not a document.`,
245
+ action: "Restore it to a JSON object, or move it aside, and run the command again.",
246
+ detail: `dev.json at ${path} parsed to ${found}, and other tenants' keys would be replaced by a write from it`,
247
+ }),
248
+ invalid: ({ at }) =>
249
+ new ConflictError({
250
+ message: `Cannot update ${path}: it is not the document Pithy keeps there.`,
251
+ action: "Fix it, or move it aside, and run the command again.",
252
+ // The key path, so the line can be found. Never the value on it, which may be the dev master key.
253
+ detail: `dev.json at ${path} failed its schema at ${at}`,
254
+ }),
255
+ });
256
+ }
257
+
258
+ /** `JSON.parse` that answers `undefined` rather than throwing — this file is hand-edited. */
259
+ function safeJson(source: string): unknown {
260
+ try {
261
+ return JSON.parse(source);
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
@@ -0,0 +1,240 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { StatePathOptions } from "../notifier/state";
7
+ import { readOptionalFile } from "../project/readOptionalFile";
8
+ import { writeBootstrapVars } from "./bootstrapVars";
9
+ import { generateDevVars } from "./generate";
10
+ import type { DevSecretsTarget } from "./targets";
11
+
12
+ /**
13
+ * The one place a value becomes a `.dev.vars` line — and the one place that makes sure the line reaches
14
+ * the Worker that has to read it.
15
+ *
16
+ * Two defects lived here, and both were silent, which is why this is a funnel rather than a fix at each
17
+ * of the four call sites that had them.
18
+ *
19
+ * **Values were written raw.** wrangler parses `.dev.vars` with `dotenv`, whose unquoted value grammar
20
+ * is `[^#\r\n]+` — so `KEY=s3cr3t#tail` hands the Worker `s3cr3t`, with no warning anywhere. A generated
21
+ * key is base64 and never contains a `#`; an OAuth client secret is whatever the provider issued, and
22
+ * this file is where those land. The failure arrives at the first sign-in, as a signature that does not
23
+ * verify, against a value that looks present in every file you would think to check.
24
+ *
25
+ * **And the line went somewhere the Worker does not read.** `pithy dev` spawns wrangler with
26
+ * `cwd: apps/<worker>`, and wrangler loads the `.dev.vars` beside the Worker's own config — so a value
27
+ * written to the project root alone landed in a file nothing reads. That was solved with a symlink into
28
+ * each `apps/<worker>/`, and the symlink produced five defects of its own. It is solved by generation
29
+ * now: see {@link ./generate}, which owns the whole destination side.
30
+ *
31
+ * **The encoding is verified, not reasoned about.** {@link encodeDevVarsValue} tries the three forms
32
+ * dotenv accepts and keeps the first one that survives a round trip through *both* readers — wrangler's
33
+ * and ours. A value no form survives is refused by name. That is deliberately stricter than a rule
34
+ * about which characters are dangerous: the rule would need updating for the shape nobody thought of,
35
+ * and the round trip already covers it.
36
+ *
37
+ * **A refusal is fail-closed, and now structurally.** The binding is the only place a `cf-secrets-store`
38
+ * secret is read from, so a refused value that left the superseded line in place handed the Worker the
39
+ * *previous* secret while every report said the value was replaced. The generated file is built from the
40
+ * sources every run rather than upserted into, so a refused value has no line to leave behind.
41
+ *
42
+ * **What this function still owns is persistence.** A generated file cannot be its own source of truth,
43
+ * so a value written here goes to the machine-local bootstrap store (`./bootstrapVars`) and the Workers'
44
+ * files are regenerated from it. That is what makes a master key minted by `pithy add secrets` survive to
45
+ * the next `pithy dev` without `.dev.vars` accumulating state nobody can regenerate.
46
+ */
47
+
48
+ /** What one value's encoding produced: the bytes to write, or the sentence saying why nothing was. */
49
+ export interface DevVarsEncoding {
50
+ /** The text that goes after `KEY=`, ready to write. `null` when no faithful encoding exists. */
51
+ encoded: string | null;
52
+ /** Why the value cannot be written, naming the secret and never its value. `null` when it can. */
53
+ refused: string | null;
54
+ }
55
+
56
+ /**
57
+ * `dotenv@16.3.1`'s line grammar, copied verbatim from the parser wrangler ships
58
+ * (`node_modules/wrangler/wrangler-dist/cli.js`, `var LINE = …`).
59
+ *
60
+ * It is exported so a test can assert it still appears in that bundle byte for byte. A copy that drifts
61
+ * from wrangler's is worse than no copy at all: {@link encodeDevVarsValue} verifies every value against
62
+ * it, so a drift would silently bless an encoding the Worker reads differently.
63
+ */
64
+ export const DOTENV_LINE =
65
+ /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
66
+
67
+ /**
68
+ * Parse a `.dev.vars` body the way **wrangler** does — quoted spans, inline `#` comments, and the
69
+ * `\n`/`\r` expansion a double-quoted value gets. This is the reader whose answer the Worker actually
70
+ * receives, so it is the one an encoding has to satisfy.
71
+ *
72
+ * Not a replacement for `parseDevVars`. That one is what pithy's own commands read the file with, and
73
+ * the two disagree — on `#`, on backticks, on escapes. Both are checked, because a value that reads one
74
+ * way in the Worker and another in `pithy doctor` is its own bug.
75
+ */
76
+ export function parseDotenv(source: string): Record<string, string> {
77
+ const vars: Record<string, string> = {};
78
+ const line = new RegExp(DOTENV_LINE.source, DOTENV_LINE.flags);
79
+ const body = source.toString().replace(/\r\n?/gm, "\n");
80
+ let match: RegExpExecArray | null = line.exec(body);
81
+ while (match !== null) {
82
+ const key = match[1];
83
+ let value = (match[2] ?? "").trim();
84
+ const quote = value[0];
85
+ value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
86
+ if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
87
+ if (key !== undefined) vars[key] = value;
88
+ match = line.exec(body);
89
+ }
90
+ return vars;
91
+ }
92
+
93
+ /** The three forms dotenv accepts, in the order they are tried: as-is, single-quoted, double-quoted. */
94
+ function candidates(value: string): string[] {
95
+ const forms = [value];
96
+ if (!value.includes("'")) forms.push(`'${value}'`);
97
+ if (!value.includes('"')) forms.push(`"${value}"`);
98
+ return forms;
99
+ }
100
+
101
+ /**
102
+ * Encode one value for `.dev.vars`, or refuse.
103
+ *
104
+ * A candidate is accepted only when writing `NAME=<candidate>` and reading it back gives the value
105
+ * again — through wrangler's parser *and* through pithy's. The plain form is tried first, so the
106
+ * overwhelming majority of values (base64 keys, JSON envelopes) are written exactly as they always
107
+ * were and no existing file churns.
108
+ *
109
+ * The name is checked by the same round trip rather than by a separate rule: dotenv's key grammar is
110
+ * `[\w.-]+`, so a secret named with a `:` produces a line the Worker never sees at all. Silence is the
111
+ * worst answer available for a secret, so that is a refusal too.
112
+ */
113
+ export function encodeDevVarsValue(name: string, value: string): DevVarsEncoding {
114
+ for (const candidate of candidates(value)) {
115
+ const line = `${name}=${candidate}\n`;
116
+ if (parseDotenv(line)[name] === value && parseDevVars(line)[name] === value) {
117
+ return { encoded: candidate, refused: null };
118
+ }
119
+ }
120
+ return {
121
+ encoded: null,
122
+ refused: `${name} cannot be written to .dev.vars — no quoting survives its value. It belongs in the dev secrets file; see #153.`,
123
+ };
124
+ }
125
+
126
+ /** What {@link writeDevVars} needs. Every seam defaults to the real project. */
127
+ export interface WriteDevVarsOptions {
128
+ /** The project root — owner of `apps/`, of the `.dev.vars.local` files, and of the project's name. */
129
+ projectDir: string;
130
+ /**
131
+ * The values to record in `dev.json`, by variable name. **Only what no registry declares** — a
132
+ * Turnstile sitekey, a machine-local endpoint. A registry secret's value belongs in `secrets.jsonc`,
133
+ * which the generator reads directly (#179). Empty records nothing and regenerates anyway.
134
+ */
135
+ values: Record<string, string>;
136
+ /** The Worker directories to generate into. Defaults to every discovered Worker with a `wrangler.jsonc`. */
137
+ workerDirs?: string[];
138
+ /** The Workers whose registries decide which secrets are materialised. Defaults to every one composing `secrets`. */
139
+ targets?: DevSecretsTarget[];
140
+ /** Where the Pithy config directory is. Defaults to the real one; a seam so a test writes its own. */
141
+ paths?: StatePathOptions;
142
+ }
143
+
144
+ /** What one write did. Every list is sorted, so two runs of the same state read the same. */
145
+ export interface WriteDevVarsResult {
146
+ /** The variable names actually recorded — the ones an encoding survives. */
147
+ written: string[];
148
+ /**
149
+ * One sentence per value or Worker directory that did not get one: a value no quoting survives, a
150
+ * `.dev.vars` pithy did not generate, a directory this project may not write into. Never a value.
151
+ */
152
+ refused: string[];
153
+ /** Worker directories whose `.dev.vars` this run wrote. */
154
+ generated: string[];
155
+ /** Worker directories whose `.dev.vars` already held exactly these bytes. No bytes written, mtime intact. */
156
+ unchanged: string[];
157
+ /** Worker directories whose `.dev.vars` was a symlink from the old shared-file design, now a real file. */
158
+ relinked: string[];
159
+ /** Every variable name the generated files carry, sorted. Names only — never a value, anywhere. */
160
+ names: string[];
161
+ }
162
+
163
+ /**
164
+ * Record values in the machine-local bootstrap store, then regenerate every Worker's `.dev.vars` from it.
165
+ *
166
+ * Mode `0600` on every file written, the same as the dev secrets file: this one holds the dev master key
167
+ * and every `cf-secrets-store` value, and the umask default is world-readable.
168
+ */
169
+ export async function writeDevVars(options: WriteDevVarsOptions): Promise<WriteDevVarsResult> {
170
+ const written: string[] = [];
171
+ const refused: string[] = [];
172
+ const encoded: Record<string, string> = {};
173
+ for (const name of Object.keys(options.values).sort()) {
174
+ const value = options.values[name];
175
+ if (value === undefined) continue;
176
+ // Encoded here only to decide whether the value can be delivered at all. The bootstrap store keeps
177
+ // the plain value — quoting is `.dev.vars` grammar, and a quoted value round-tripped through a JSON
178
+ // file would acquire a second layer of it on the next generation.
179
+ const result = encodeDevVarsValue(name, value);
180
+ if (result.encoded === null) {
181
+ if (result.refused !== null) refused.push(result.refused);
182
+ continue;
183
+ }
184
+ encoded[name] = value;
185
+ written.push(name);
186
+ }
187
+
188
+ await writeBootstrapVars(options.projectDir, encoded, options.paths ?? {});
189
+ // Regenerated from the **sources**, never from what was just recorded. A generator handed a value set
190
+ // by its caller is a generator that can disagree with the files it claims to read — which is exactly
191
+ // how a `cf-secrets-store` secret came to reach a Worker one `pithy seed` after it was rotated (#179).
192
+ const generated = await generateDevVars({
193
+ projectDir: options.projectDir,
194
+ ...(options.workerDirs !== undefined ? { workerDirs: options.workerDirs } : {}),
195
+ ...(options.targets !== undefined ? { targets: options.targets } : {}),
196
+ ...(options.paths !== undefined ? { paths: options.paths } : {}),
197
+ });
198
+
199
+ return {
200
+ written,
201
+ refused: [...refused, ...generated.refused].sort(),
202
+ generated: generated.generated,
203
+ unchanged: generated.unchanged,
204
+ relinked: generated.relinked,
205
+ names: generated.names,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The file's bytes, or `null` when there is no file — and **only** when there is no file.
211
+ *
212
+ * The rule and its three producers now live in {@link readOptionalFile} (`../project/`). What stays here
213
+ * is what is `.dev.vars`-shaped: the sentence an adopter reads, the mode it names, and *why* absence had
214
+ * to stop meaning "unreadable" for this file in particular. Reading `EACCES` or `EIO` as "empty" meant
215
+ * the next content was built from an empty base and renamed over a file full of values this process never
216
+ * saw — the adopter's `CLOUDFLARE_API_TOKEN` and every other line, gone, with the run reporting a clean
217
+ * write. The same defect `readSource` was written to end for the dev secrets file, in the file beside it,
218
+ * twice over.
219
+ *
220
+ * The wrapped error carries the node error as `cause`. Its message is a path and an errno, never a line
221
+ * of the file.
222
+ *
223
+ * **Exported because reading this file honestly is not only the writer's problem.** `pithy add` asks the
224
+ * same question — is this secret already here? — and its own `.catch(() => "")` answered "no" for an
225
+ * unreadable file, so `add` minted a second value and the project held two under one name, one in each
226
+ * file. Three readers of one file is how one of them keeps getting it wrong.
227
+ */
228
+ export async function readDevVarsSource(path: string): Promise<string | null> {
229
+ return readOptionalFile(path, {
230
+ unreadable: ({ code, cause }) =>
231
+ new InternalError(
232
+ {
233
+ message: ".dev.vars is there and could not be read.",
234
+ action: `Check ${path} and its permissions. It should be a file, mode 600.`,
235
+ detail: `.dev.vars '${path}' failed to read: ${code ?? "unknown error"}`,
236
+ },
237
+ { cause },
238
+ ),
239
+ });
240
+ }