@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,428 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { lstat, unlink } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
7
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { devVarsForRegistry } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
9
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
10
+ import type { StatePathOptions } from "../notifier/state";
11
+ import { writeFileAtomic } from "../project/atomic";
12
+ import { ensureScaffoldPath } from "../project/scaffold";
13
+ import { discoverWorkers } from "../project/workers";
14
+ import { readBootstrapVars } from "./bootstrapVars";
15
+ import { encodeDevVarsValue, readDevVarsSource } from "./devVars";
16
+ import { readDevSecrets } from "./file";
17
+ import { resolveDevSecretsFile } from "./location";
18
+ import { tightenMode } from "./mode";
19
+ import { ownProperties } from "./records";
20
+ import {
21
+ type DevSecretsResolution,
22
+ type DevSecretsTarget,
23
+ resolveDevSecretsTargets,
24
+ type UnresolvableWorker,
25
+ } from "./targets";
26
+
27
+ /**
28
+ * `apps/<worker>/.dev.vars` is **generated, one per Worker** — never shared by symlink (#154).
29
+ *
30
+ * wrangler loads a `.dev.vars` from the directory it runs in and merges nothing, so every Worker needs
31
+ * its own. That was solved with one file at the project root and a symlink into each `apps/<worker>/`,
32
+ * and the symlink produced five defects: #137 (`pithy init` never wired it, so every minted secret was
33
+ * unreadable), #139 (a fresh clone has no link and nothing re-makes it), #142 (wiring the link deleted a
34
+ * real file, losing gitignored secrets with no copy anywhere), #146 (an atomic write detached the link
35
+ * into a stale private copy), and a standing policy question about a link an adopter pointed somewhere
36
+ * deliberately. None of the five can happen to a file that is generated: there is no link to wire,
37
+ * dangle, delete, or detach.
38
+ *
39
+ * It is cheap now and would not have been before #153. `.dev.vars` used to carry a dozen entries of three
40
+ * provenances; dev resolves `d1` secrets from the seeded store now, so what is generated is the bootstrap
41
+ * set — the master key, `cf-secrets-store` values, public vars. Two or three lines.
42
+ *
43
+ * ## The sources, in precedence order
44
+ *
45
+ * ```
46
+ * <config>/<project>/dev.json vars machine-local values no registry declares (./bootstrapVars)
47
+ * <config>/<project>/secrets.jsonc every cf-secrets-store secret the registry declares — wins
48
+ * <root>/.dev.vars.local every Worker's override
49
+ * <root>/apps/<w>/.dev.vars.local that Worker's override — wins
50
+ * ↓
51
+ * <root>/apps/<w>/.dev.vars generated, never edited
52
+ * ```
53
+ *
54
+ * **The dev secrets file is a source, and that is #179.** It used to reach here one `pithy seed` later,
55
+ * through a copy: the seeder routed a `cf-secrets-store` value into `dev.json` under `vars`, and this
56
+ * read *that*. So the file named "the dev secrets file" and the file a Worker's bindings were built from
57
+ * were two files holding one secret — rotating in `secrets.jsonc` did not reach the Worker until
58
+ * something re-seeded, a removed secret's value stayed in `dev.json` forever (that module said so: "A
59
+ * value is never removed here"), and the header below named a source it did not read.
60
+ *
61
+ * **`dev.json` keeps only what no registry declares.** A Turnstile sitekey is a real machine-local value
62
+ * with no registry entry and belongs there. A name the registry *does* declare is dropped from that half
63
+ * outright, whatever it says — that is what makes deleting a secret from `secrets.jsonc` delete it from
64
+ * every generated file, rather than falling back to a stale copy. `pithy doctor` names each one.
65
+ *
66
+ * **`.dev.vars.local` is for overrides, not for variables.** `wrangler.jsonc`'s `vars` block is where a
67
+ * value that should exist in production belongs — it is committed, reviewed, and deployed with the
68
+ * Worker. A variable placed only in a `.local` file works in dev and is simply absent in production, and
69
+ * that failure lands at deploy, far from the cause. `pithy doctor` names any `.local` key that is neither
70
+ * a registry secret nor declared in `wrangler.jsonc` `vars`: visible, not forbidden, because shadowing a
71
+ * real value for an afternoon is legitimate and common.
72
+ *
73
+ * **Both scopes, and the per-Worker one is not optional.** Generated files legitimately differ per Worker,
74
+ * because a Worker's bindings come from the capabilities *it* composes. An override that can only speak
75
+ * to every Worker at once cannot express "point this one somewhere else".
76
+ *
77
+ * **Nothing here writes `wrangler.jsonc` `vars`, and that is deliberate.** `env.<name>.vars` *replaces*
78
+ * the top-level block rather than merging it, so every environment has to repeat every variable — and a
79
+ * generator that wrote one block would silently drop `ENVIRONMENT` from staging. The one place that reads
80
+ * `vars` — doctor's `.local` check — therefore reads the top level **and** every environment's.
81
+ *
82
+ * ## Two rules that are the whole safety of this file
83
+ *
84
+ * **Never overwrite a file a human wrote.** The generated file opens with {@link GENERATED_MARKER}. A
85
+ * `.dev.vars` without it is the adopter's: the run refuses, names the path, and points at
86
+ * `.dev.vars.local`. It does not overwrite and it does not merge. This is #142's lesson, and that defect
87
+ * has appeared twice in `.dev.vars` handling already — building the check in is cheaper than finding it a
88
+ * third time in a file every adopter has.
89
+ *
90
+ * **Skip the write by comparing content, never mtime.** The header check already has the file in memory,
91
+ * so comparing costs a string equality. mtime is the wrong signal in at least five ways, and one of them
92
+ * is not a file at all: upgrading `@pithy-sh/auth` can add a secret to the registry with nothing in the
93
+ * project changing, so mtime reports fresh while the binding is absent. `git checkout` rewrites mtimes,
94
+ * `cp` and `cp -a` disagree about preserving them, one-second granularity is common on network mounts,
95
+ * and clocks skew on shared filesystems. The saving is a few lines of I/O; there is no mtime comparison
96
+ * anywhere in this module. What the comparison buys is not CPU but watcher churn: wrangler watches
97
+ * `.dev.vars`, and rewriting it identically on every run risks a reload for a file that never changed.
98
+ */
99
+
100
+ /** The name of the hand-authored override file, at both scopes. Never generated, never written. */
101
+ export const DEV_VARS_LOCAL = ".dev.vars.local";
102
+
103
+ /**
104
+ * The first line of every generated `.dev.vars`, and the only thing that makes overwriting one safe.
105
+ *
106
+ * Written by the generator itself on every write rather than assumed — a marker nothing emits is a
107
+ * marker that eventually goes missing, and the file it protects is the one holding the master key.
108
+ */
109
+ export const GENERATED_MARKER = "# Generated by pithy. Do not edit.";
110
+
111
+ /**
112
+ * The whole header, and **the same bytes for every caller.**
113
+ *
114
+ * It carried the resolved secrets-file path, which one caller passed and another did not — so the two
115
+ * generation passes in a single `pithy dev` produced two different files and rewrote each other, every
116
+ * run, defeating the content comparison this module exists for. A header that varies by caller is a
117
+ * header that churns; a header that varies by machine churns across a shared checkout. Neither path is
118
+ * in it: `pithy doctor` prints both, every run, and that is its job.
119
+ */
120
+ export const GENERATED_HEADER = [
121
+ GENERATED_MARKER,
122
+ "# Sources: the dev secrets file and this machine's dev.json. Run pithy doctor for both paths.",
123
+ `# To override a value locally, put it in ${DEV_VARS_LOCAL} — here, or at the project root.`,
124
+ ];
125
+
126
+ /** Whether a `.dev.vars` body is one pithy generated — the header, on the first line, exactly. */
127
+ export function isGeneratedDevVars(content: string): boolean {
128
+ return content.startsWith(GENERATED_MARKER);
129
+ }
130
+
131
+ /**
132
+ * The bytes of one Worker's `.dev.vars`.
133
+ *
134
+ * Keys are sorted, so two runs of the same state produce the same file and the content comparison below
135
+ * is about the values rather than about iteration order. Every value goes through
136
+ * {@link encodeDevVarsValue}, which verifies its quoting against wrangler's own dotenv parser and ours
137
+ * before accepting it — a value no form survives is refused by name rather than written and misread.
138
+ */
139
+ export function renderDevVars(values: Record<string, string>): string {
140
+ const lines = [...GENERATED_HEADER, ""];
141
+ const refused: string[] = [];
142
+ for (const name of Object.keys(values).sort()) {
143
+ const value = values[name];
144
+ if (value === undefined) continue;
145
+ const encoded = encodeDevVarsValue(name, value);
146
+ if (encoded.encoded === null) {
147
+ if (encoded.refused !== null) refused.push(encoded.refused);
148
+ continue;
149
+ }
150
+ lines.push(`${name}=${encoded.encoded}`);
151
+ }
152
+ for (const reason of refused) lines.push(`# ${reason}`);
153
+ return `${lines.join("\n")}\n`;
154
+ }
155
+
156
+ /** What {@link generateDevVars} needs. Every seam defaults to the real project. */
157
+ export interface GenerateDevVarsOptions {
158
+ /** The project root — owner of `apps/`, of the root `.dev.vars.local`, and of the project's name. */
159
+ projectDir: string;
160
+ /** The Worker directories to generate into. Defaults to every discovered Worker with a `wrangler.jsonc`. */
161
+ workerDirs?: string[];
162
+ /** Where the Pithy config directory is. Defaults to the real one; a seam so a test reads its own. */
163
+ paths?: StatePathOptions;
164
+ /**
165
+ * The whole value set, bypassing both sources. A seam for a test that has no project on disk — the
166
+ * real callers pass nothing, because a caller that assembled these itself would be the second answer
167
+ * to which files a Worker's bindings come from.
168
+ */
169
+ values?: Record<string, string>;
170
+ /**
171
+ * The Workers whose registries decide which secrets are materialised. Defaults to every one composing
172
+ * `secrets`. A seam, and the one `pithy add` uses to hand over a freshly-reloaded composition.
173
+ */
174
+ targets?: DevSecretsTarget[];
175
+ /**
176
+ * The Workers whose `pithy.config.ts` would not import, for {@link GenerateDevVarsResult.unresolvable}
177
+ * to state. Travels with `targets`, because a caller that resolved the composition itself is the only
178
+ * one that knows what failed while it did — supplied by neither real caller, which both let this
179
+ * module resolve.
180
+ */
181
+ unresolvable?: readonly UnresolvableWorker[];
182
+ }
183
+
184
+ /** What one generation run did. Every list is sorted, so two runs of the same state read the same. */
185
+ export interface GenerateDevVarsResult {
186
+ /** Worker directories whose `.dev.vars` this run wrote. */
187
+ generated: string[];
188
+ /** Worker directories whose `.dev.vars` already held exactly these bytes. Not rewritten, not touched. */
189
+ unchanged: string[];
190
+ /**
191
+ * One sentence per Worker directory nothing was written into, and why — a `.dev.vars` pithy did not
192
+ * generate, or a directory this project may not write into (#167). Actionable, never a value. A caller
193
+ * for which generating *was* the point of the run exits non-zero on a non-empty list.
194
+ */
195
+ refused: string[];
196
+ /**
197
+ * Worker directories whose `.dev.vars` was a symlink from the old shared-file design, replaced with a
198
+ * generated file. A link holds no content, so nothing was lost — but which secrets a Worker runs with
199
+ * did change, so it is never silent.
200
+ */
201
+ relinked: string[];
202
+ /** The variable names the generated files carry, sorted. Names only — never a value, anywhere. */
203
+ names: string[];
204
+ /**
205
+ * One sentence per Worker whose `pithy.config.ts` would not import — **the consequence first, then the
206
+ * cause** (#199).
207
+ *
208
+ * Its own list rather than a line in {@link refused}, because nothing was refused: the file *was*
209
+ * written, down to its header, and that is deliberate. A registry nobody can read has no honest answer
210
+ * about which secrets a Worker gets, and the previous answer came from the `dev.json` copy #179 exists
211
+ * to delete. So the Worker really does start with no bindings, and the only thing wrong was that
212
+ * nothing said so.
213
+ *
214
+ * Both facts in one sentence on purpose. Reported separately — the import error in one block, the
215
+ * absent bindings inferred from another — is the adopter correlating them, and the adopter here is
216
+ * mid-edit on the config they have just broken and looking at the wrong suspect. Never a value.
217
+ */
218
+ unresolvable: string[];
219
+ }
220
+
221
+ /**
222
+ * Generate every Worker's `.dev.vars`. The engine behind `pithy dev` and `pithy seed`.
223
+ *
224
+ * Idempotent by comparison rather than by convergence: a run that changes nothing writes no bytes, so the
225
+ * file's mtime is unchanged and wrangler's watcher has nothing to react to. That is the same rule #149's
226
+ * seeder follows — compare the stored value rather than re-encrypt — and it belongs here for the same
227
+ * reason.
228
+ */
229
+ export async function generateDevVars(options: GenerateDevVarsOptions): Promise<GenerateDevVarsResult> {
230
+ // Resolved once, here, so the value set and the report of what could not be asked for it come from one
231
+ // answer. Two resolutions would be two chances to disagree about which Workers were readable.
232
+ const resolution = await devSecretsResolution(options);
233
+ const bootstrap = options.values ?? (await devVarsSources(options, resolution.targets));
234
+ const rootLocal = await readLocalOverrides(options.projectDir);
235
+ const dirs = options.workerDirs ?? (await workerDirs(options.projectDir));
236
+
237
+ const generated: string[] = [];
238
+ const unchanged: string[] = [];
239
+ const refused: string[] = [];
240
+ const relinked: string[] = [];
241
+ const names = new Set<string>();
242
+
243
+ for (const dir of dirs) {
244
+ // The directory, gated before anything is written into it (#167). `discoverWorkers` builds
245
+ // `apps/<name>` out of a `readdir` that follows whatever `apps` is, so a symlink at either put a file
246
+ // holding the project's master key somewhere outside the project. Reported rather than thrown: this
247
+ // runs inside `pithy dev`, and one planted link in a directory no Worker of theirs owns must not stop
248
+ // every other Worker from getting its bindings.
249
+ const gate = await ensureScaffoldPath(options.projectDir, dir).then(() => null, refusalOf);
250
+ if (gate !== null) {
251
+ refused.push(`${dir}: no .dev.vars was generated. ${gate}`);
252
+ continue;
253
+ }
254
+
255
+ // Worker-scoped overrides win over the root's, which win over the bootstrap set. One merge, so the
256
+ // precedence is stated once and the file's line order (sorted) carries no meaning of its own.
257
+ const values = { ...bootstrap, ...rootLocal, ...(await readLocalOverrides(dir)) };
258
+ for (const name of Object.keys(values)) names.add(name);
259
+ const content = renderDevVars(values);
260
+ const path = join(dir, ".dev.vars");
261
+
262
+ // A symlink is the old design's own artifact — nothing but pithy ever made one here — and it holds no
263
+ // content, so removing it loses nothing. The file it pointed at is untouched and still on disk.
264
+ const entry = await lstat(path).catch(() => null);
265
+ if (entry?.isSymbolicLink()) {
266
+ await unlink(path);
267
+ relinked.push(dir);
268
+ } else {
269
+ const existing = await readDevVarsSource(path);
270
+ if (existing !== null && !isGeneratedDevVars(existing)) {
271
+ refused.push(
272
+ `${path} was not generated by pithy, so nothing was written to it. Keep local values in ${join(dir, DEV_VARS_LOCAL)} — pithy merges that file in and never rewrites it.`,
273
+ );
274
+ continue;
275
+ }
276
+ // Content, never mtime. The header check above already read the file, so this is a string equality.
277
+ if (existing === content) {
278
+ unchanged.push(dir);
279
+ await tightenMode(path);
280
+ continue;
281
+ }
282
+ }
283
+
284
+ // Reported, never thrown. A directory that vanished between discovery and here, a read-only mount, a
285
+ // full disk — each is one Worker without its bindings, and `pithy dev` has to start the others and
286
+ // say which one it could not write. A throw would leave every note unprinted and the session dead
287
+ // over a directory no Worker of theirs may even own.
288
+ const failure = await writeFileAtomic(path, content, { mode: 0o600 }).then(() => null, refusalOf);
289
+ if (failure !== null) {
290
+ refused.push(`${path} could not be written, so that Worker has no bindings. ${failure}`);
291
+ continue;
292
+ }
293
+ // Unconditionally and after the write, the same rule the secrets file follows: a file another tool
294
+ // created at the umask holds the master key at 0644 until something narrows it. Narrowing only.
295
+ await tightenMode(path);
296
+ generated.push(dir);
297
+ }
298
+
299
+ return {
300
+ generated: generated.sort(),
301
+ unchanged: unchanged.sort(),
302
+ refused: refused.sort(),
303
+ relinked: relinked.sort(),
304
+ names: [...names].sort(),
305
+ unresolvable: resolution.unresolvable.map(unresolvableSentence).sort(),
306
+ };
307
+ }
308
+
309
+ /**
310
+ * One Worker's unresolvable config, as the sentence that reaches a terminal.
311
+ *
312
+ * **The consequence leads.** "Could not load pithy.config.ts" is the cause and answers the wrong
313
+ * question: the adopter already knows they are editing that file. What they do not know — and what
314
+ * changed under them at #179 — is that the Worker in front of them now resolves none of its own
315
+ * secrets, which is why they are about to go looking at the secrets change instead of at their own
316
+ * typo. The `.dev.vars` path is named because that is the file they will open to check.
317
+ *
318
+ * **"Of its own", because a sibling's registry still reaches this file.** {@link devVarsSources} merges
319
+ * every resolved Worker's registry into one set and writes it to every Worker directory, so in a project
320
+ * where another Worker composes `secrets` the generated file is not empty — it simply holds nothing this
321
+ * Worker was asked about. A sentence claiming the file was "generated empty" is true of the
322
+ * single-Worker project and a lie in the multi-Worker one, and a message that overstates on some
323
+ * projects is one an adopter learns to disbelieve on all of them.
324
+ */
325
+ function unresolvableSentence(worker: UnresolvableWorker): string {
326
+ return `${worker.name} starts with no bindings of its own: its pithy.config.ts did not import, so nothing knows which secrets it declares and none reached ${join(worker.dir, ".dev.vars")}. ${worker.reason}`;
327
+ }
328
+
329
+ /**
330
+ * The composition this run generates against, and what it could not read.
331
+ *
332
+ * A caller that supplied either half already resolved the composition itself — `pithy add`, holding a
333
+ * reloaded one — and re-resolving would answer against a different import than the one it acted on. A
334
+ * caller that supplied `values` bypassed the sources outright and has no registry to speak of.
335
+ *
336
+ * The catch keeps {@link devVarsSources}' "never throws" contract for the one thing left that can:
337
+ * a capability whose registry aggregation itself fails. Costing a Worker its bindings is the same
338
+ * outcome as an unreadable config, and it is `pithy doctor`'s to name.
339
+ */
340
+ async function devSecretsResolution(options: GenerateDevVarsOptions): Promise<DevSecretsResolution> {
341
+ if (options.targets !== undefined || options.unresolvable !== undefined) {
342
+ return { targets: options.targets ?? [], unresolvable: [...(options.unresolvable ?? [])] };
343
+ }
344
+ if (options.values !== undefined) return { targets: [], unresolvable: [] };
345
+ return resolveDevSecretsTargets(options.projectDir).catch(() => ({ targets: [], unresolvable: [] }));
346
+ }
347
+
348
+ /**
349
+ * The two machine-local sources, merged: `dev.json`'s `vars` for what no registry declares, and every
350
+ * `cf-secrets-store` secret `secrets.jsonc` states.
351
+ *
352
+ * **The registry decides membership of both halves, and that is the whole point.** A name it declares is
353
+ * materialised from `secrets.jsonc` or not at all; a name it does not declare can only come from
354
+ * `dev.json`. So there is exactly one file per value and no precedence question to get wrong — and
355
+ * deleting a secret from `secrets.jsonc` deletes it from every generated file rather than falling back
356
+ * to the copy the old seeder left behind.
357
+ *
358
+ * **Never throws.** This runs inside `pithy dev`. A project with no name to key a config directory on and
359
+ * a `secrets.jsonc` that will not parse each cost the bindings they would have contributed, and are
360
+ * reported by `pithy seed` and `pithy doctor`, which are the commands whose job it is to say so. Stopping
361
+ * every Worker in the project over one of them is the worse answer.
362
+ *
363
+ * **A Worker whose config will not import used to be on that list, and that was the bug (#199).** It
364
+ * reached here as an absent target, indistinguishable from a Worker that declares nothing, and neither
365
+ * of the two commands named above ever saw it — `pithy dev` wrote the empty file and said nothing at all.
366
+ * The failure travels beside the targets now and {@link generateDevVars} states it.
367
+ */
368
+ async function devVarsSources(
369
+ options: GenerateDevVarsOptions,
370
+ targets: readonly DevSecretsTarget[],
371
+ ): Promise<Record<string, string>> {
372
+ const paths = options.paths ?? {};
373
+ const registry: SecretRegistry = ownProperties(
374
+ Object.assign({}, ...targets.map((target) => target.registry)) as SecretRegistry,
375
+ );
376
+ const secrets = await materialisedSecrets(options.projectDir, registry, paths);
377
+
378
+ const values: Record<string, string> = {};
379
+ for (const [name, value] of Object.entries(await readBootstrapVars(options.projectDir, paths))) {
380
+ // A registry name is the secrets file's to answer, whatever `dev.json` still holds. This is the line
381
+ // that makes a removal take effect.
382
+ if (Object.hasOwn(registry, name)) continue;
383
+ values[name] = value;
384
+ }
385
+ return { ...values, ...secrets };
386
+ }
387
+
388
+ /** Every `cf-secrets-store` secret this project states, as `.dev.vars` values. Empty on any failure. */
389
+ async function materialisedSecrets(
390
+ projectDir: string,
391
+ registry: SecretRegistry,
392
+ paths: StatePathOptions,
393
+ ): Promise<Record<string, string>> {
394
+ if (Object.keys(registry).length === 0) return {};
395
+ try {
396
+ const path = await resolveDevSecretsFile(projectDir, paths);
397
+ return devVarsForRegistry(await readDevSecrets(path), registry, path);
398
+ } catch {
399
+ return {};
400
+ }
401
+ }
402
+
403
+ /**
404
+ * One directory's `.dev.vars.local`, parsed. Empty when there is none, which is the ordinary state.
405
+ *
406
+ * Read with `parseDevVars` — pithy's own reader — because these values are merged and re-encoded before
407
+ * they reach wrangler. The encoding a Worker actually receives is decided by {@link renderDevVars}.
408
+ */
409
+ export async function readLocalOverrides(dir: string): Promise<Record<string, string>> {
410
+ const source = await readDevVarsSource(join(dir, DEV_VARS_LOCAL));
411
+ return source === null ? {} : parseDevVars(source);
412
+ }
413
+
414
+ /**
415
+ * A gate refusal as one sentence. `PithyError`'s `action` is where the whole answer lives — "Remove it, or
416
+ * pick another name" — and `Error.message` alone names the problem and not the fix. `detail` is never
417
+ * included: it is throw-site context, and these lines reach a terminal.
418
+ */
419
+ function refusalOf(error: unknown): string {
420
+ if (error instanceof PithyError) return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
421
+ return error instanceof Error ? error.message : String(error);
422
+ }
423
+
424
+ /** Every Worker directory wrangler will run in — the ones with a `wrangler.jsonc` to load `.dev.vars` beside. */
425
+ async function workerDirs(projectDir: string): Promise<string[]> {
426
+ const workers = await discoverWorkers(projectDir).catch(() => []);
427
+ return workers.filter((worker) => worker.hasWrangler !== false).map((worker) => worker.dir);
428
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join } from "node:path";
5
+ import { projectConfigDir, type StatePathOptions, stateDir } from "../notifier/state";
6
+ import { loadProject, requireProjectName } from "../project/config";
7
+ import { ensureOwnerOnlyDirFor } from "./mode";
8
+
9
+ /**
10
+ * Where a project's dev secrets live: `<config>/<project>/secrets.jsonc` — **outside the checkout, and
11
+ * with nothing inside it pointing at them** (#156).
12
+ *
13
+ * **Why not in the repo.** `.dev.vars` sits in a worker's directory because *wrangler* reads it there;
14
+ * the location is not ours to choose. Nothing but our own CLI reads the secrets file, and the CLI
15
+ * resolves its own paths — so the file does not need to appear in the project, it needs to be *found*.
16
+ * Everything that followed from having it in the project followed from that one unexamined assumption:
17
+ * a mint into a file the project did not ignore, a `.tmp` sibling one SIGINT away from a published
18
+ * tarball (#145), a worktree with no secrets at all (#155), and an `rm -rf` on a checkout taking every
19
+ * dev credential with it. Not a symlink either: a link puts the file back in the field of view of
20
+ * every tool that follows one.
21
+ *
22
+ * **This is #131's directory, not a second convention.** `<config>/<project>/dev.json` was already
23
+ * there; this is its neighbor, resolved through the same {@link stateDir} — `$PITHY_CONFIG_DIR`, then
24
+ * `%APPDATA%\pithy`, then `$XDG_CONFIG_HOME/pithy`, then `~/.config/pithy`. Two implementations of
25
+ * "where does config live" is the defect shape, and the Windows branch is the half a second one forgets.
26
+ *
27
+ * **Keyed on the project name, which is a collision an adopter can hit.** Two unrelated projects both
28
+ * called `app` share one file, and renaming a project orphans the old directory. Dev-only values, so
29
+ * this is friction rather than danger — but it is invisible friction, so `pithy doctor` prints the
30
+ * resolved path on every run and names an orphaned directory when it finds one.
31
+ */
32
+
33
+ /** The file's name inside the project's config directory. Undotted: nothing here is hidden from anything. */
34
+ export const DEV_SECRETS_FILE_NAME = "secrets.jsonc";
35
+
36
+ /**
37
+ * The project's own directory under the Pithy config directory — the same one `dev.json` sits in.
38
+ *
39
+ * The join is {@link projectConfigDir}'s, and so is the rule that this name may become a path segment
40
+ * (#212). It used to be stated at every call site instead, which is where a rule is stated three times
41
+ * and then not a fourth.
42
+ */
43
+ export function devSecretsDir(project: string, options: StatePathOptions = {}): string {
44
+ return projectConfigDir(project, options);
45
+ }
46
+
47
+ /** `<config>/<project>/secrets.jsonc`, from a project name that has already been resolved. */
48
+ export function devSecretsFile(project: string, options: StatePathOptions = {}): string {
49
+ return join(devSecretsDir(project, options), DEV_SECRETS_FILE_NAME);
50
+ }
51
+
52
+ /**
53
+ * The secrets file for a project root: load its `pithy.config.ts`, require a `name`, resolve the path.
54
+ *
55
+ * **`requireProjectName`, never a guess.** It is the same gate every provisioned resource name goes
56
+ * through, and the reason is the same one: a fallback that can differ between checkouts — an
57
+ * alphabetically-first worker, a directory basename — would give a worktree a different set of secrets
58
+ * from the checkout it was cut from, silently, which is the failure #155 reported. A nameless project
59
+ * gets that command's actionable error rather than a file somewhere nobody can predict.
60
+ *
61
+ * Throws. A caller that must not — `pithy doctor` — catches, and every one that must not proceed
62
+ * without a place to put a credential should not proceed.
63
+ */
64
+ export async function resolveDevSecretsFile(projectDir: string, options: StatePathOptions = {}): Promise<string> {
65
+ return devSecretsFile(requireProjectName(await loadProject(projectDir)), options);
66
+ }
67
+
68
+ /**
69
+ * Make sure the directory holding `file` exists and is `0700`, on this call and on every later one.
70
+ *
71
+ * `mkdir`'s `mode` only applies to a directory it creates, and it is masked by the umask besides — so a
72
+ * directory already there keeps whatever mode it was made with. The narrowing runs unconditionally for
73
+ * the same reason the file's does: the case it exists for is the directory somebody else created.
74
+ *
75
+ * The listing is the finding, not just the bytes. `ls <config>/<project>/` names every secret this
76
+ * project has — which provider, which vendor, which capability — and that is worth 0700 on its own.
77
+ */
78
+ export function ensureDevSecretsDir(file: string): Promise<void> {
79
+ return ensureOwnerOnlyDirFor(file);
80
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Stats } from "node:fs";
5
+ import { chmod, mkdir, stat } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+
8
+ /** Group and other bits — everything a file holding a session key or a client secret must not carry. */
9
+ const SHARED_BITS = 0o077;
10
+
11
+ /**
12
+ * Take the group and other bits off a dev secrets file, and change nothing else.
13
+ *
14
+ * **Shared by both files, because the rule is about their contents and not about their names.**
15
+ * `.dev.vars` got this first; the secrets file did not, and it is the more sensitive of the two —
16
+ * `.dev.vars` holds env bindings and the dev master key, the JSONC file holds every application secret
17
+ * including the OAuth client secrets. Two funnels, one rule, and a private copy in each is how they drift.
18
+ *
19
+ * **Narrowing only.** An adopter's deliberate 0400 survives, 0664 becomes 0600. A mode is never widened
20
+ * from here: this runs on every write, and a rule that could widen would be a rule that eventually does.
21
+ *
22
+ * **Only a regular file we own.** A directory or a device at that path is not ours to re-mode, and
23
+ * neither is another account's file — on a shared checkout that would be an unrequested change to
24
+ * somebody else's permissions. Best effort throughout: the bytes are already written, and a mode that
25
+ * could not be set is not worth failing a `pithy dev` over.
26
+ */
27
+ export function tightenMode(path: string): Promise<void> {
28
+ return tighten(path, (entry) => entry.isFile());
29
+ }
30
+
31
+ /**
32
+ * The same rule for the directory the secrets file sits in — `<config>/<project>/`, 0700.
33
+ *
34
+ * A directory is not ours to re-mode in general, which is why {@link tightenMode} refuses one. This one
35
+ * is: `pithy` creates `<config>/<project>/` itself, for its own files (#156, #131). Its mode matters
36
+ * separately from the file's, because a listing of it is the inventory of every secret name this project
37
+ * has — `auth-google-credentials` names the provider, `stripe-webhook-secret` names the vendor — and
38
+ * `mkdir`'s `mode` argument only applies on creation. A directory an older pithy, a `cp -r`, or a restore
39
+ * left at 0755 would otherwise stay 0755 for as long as the project exists.
40
+ */
41
+ export function tightenDirMode(path: string): Promise<void> {
42
+ return tighten(path, (entry) => entry.isDirectory());
43
+ }
44
+
45
+ /**
46
+ * Make sure the directory holding `file` exists and is `0700`, on this call and on every later one.
47
+ *
48
+ * `mkdir`'s `mode` only applies to a directory it creates, and it is masked by the umask besides — so a
49
+ * directory already there keeps whatever mode it was made with. The narrowing runs unconditionally for
50
+ * the same reason a file's does: the case it exists for is the directory somebody else created.
51
+ *
52
+ * **One implementation, because there are three kinds of file under this root now** — `secrets.jsonc`,
53
+ * `dev.json`, and the account's `cloudflare.json` — and every one of them is a credential whose *listing*
54
+ * is a finding on its own. A private copy per writer is how the third one lands at the umask default.
55
+ */
56
+ export async function ensureOwnerOnlyDirFor(file: string): Promise<void> {
57
+ const dir = dirname(file);
58
+ await mkdir(dir, { recursive: true, mode: 0o700 });
59
+ await tightenDirMode(dir);
60
+ }
61
+
62
+ /** The one narrowing, so the file rule and the directory rule cannot drift apart. */
63
+ async function tighten(path: string, accept: (entry: Stats) => boolean): Promise<void> {
64
+ const entry = await stat(path).catch(() => null);
65
+ if (entry === null || !accept(entry)) return;
66
+ const us = process.geteuid?.();
67
+ if (us !== undefined && entry.uid !== us) return;
68
+ const mode = entry.mode & 0o7777;
69
+ if ((mode & SHARED_BITS) === 0) return;
70
+ await chmod(path, mode & ~SHARED_BITS).catch(() => {});
71
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Maps keyed by a **secret name**, made safe to read with a plain `record[name]`.
6
+ *
7
+ * A secret name is not ours. A capability author picks it, an adopter types it into
8
+ * the dev secrets file, and `constructor`, `toString` and `valueOf` are all legal ones. On an ordinary
9
+ * object literal every one of those reads back a function from `Object.prototype`, so a secret named
10
+ * for one is "already present" in an empty file, "declared" by every registry, and "already in
11
+ * `.dev.vars`" in a project that has none.
12
+ *
13
+ * Four rounds of review on this branch each found `Object.hasOwn` missing from one more lookup than the
14
+ * last: the loader, the merge, the seeder's two filters, the doctor's `in`. Guarding each site is the
15
+ * discipline that failed four times. **A map with no prototype cannot have the bug**, so the fix is
16
+ * applied where the map is built rather than where it is read — once per boundary, and it covers the
17
+ * lookup nobody has written yet.
18
+ */
19
+
20
+ /**
21
+ * A copy of `record` with no prototype, so every lookup on it is an own-property lookup.
22
+ *
23
+ * Copy rather than `Object.setPrototypeOf`: the input is usually somebody else's object — a parsed
24
+ * `.dev.vars`, a composed registry — and mutating its prototype from here would be action at a
25
+ * distance. `Object.keys`, `Object.entries`, spread and `JSON.stringify` all behave identically on the
26
+ * result; only the inherited members are gone, and nothing here ever wanted them.
27
+ */
28
+ export function ownProperties<T>(record: Record<string, T>): Record<string, T> {
29
+ return Object.assign(Object.create(null) as Record<string, T>, record);
30
+ }