@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,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { DevSecretsSeedReport } from "./seed";
5
+
6
+ /**
7
+ * What a seeding run says out loud — shared by `pithy add`, `pithy seed`, and `pithy dev`, so the same
8
+ * state reads identically whichever command reached it.
9
+ *
10
+ * **Silence is the default.** A run that seeded nothing new says nothing: `pithy dev` seeds on every
11
+ * start, and a line per start about secrets that have not changed since last week is noise that trains
12
+ * people to skip the block where the one real problem eventually appears. Only what this run *changed*,
13
+ * and what stopped it from running at all, gets a line.
14
+ *
15
+ * **`missing` and `undeclared` are deliberately not among them.** Both are standing states rather than
16
+ * run outcomes, and both were wrong here for the same reason twice over. `missing`: auth declares four
17
+ * OAuth credential pairs and almost every project sets none, so naming them put four names in front of
18
+ * every `pithy dev` and every `pithy seed`, forever, about nothing that had changed. `undeclared`: this
19
+ * runs inside `pithy add`, which has just rewritten `pithy.config.ts` — and the process is still holding
20
+ * the module it imported before that write, so `pithy add auth` reported the value it had itself just
21
+ * minted as one no capability declares. A snapshot taken mid-change is not a standing state.
22
+ * `pithy doctor` loads the config fresh, in its own process, and is where both of those live.
23
+ *
24
+ * **A value never appears here.** Names only — these lines reach a terminal scrollback and `logs/dev.log`.
25
+ */
26
+ export function renderDevSecretsNotes(report: DevSecretsSeedReport): string[] {
27
+ const lines: string[] = [];
28
+ // The path, not the file's name. It is outside the checkout now (#156), so "minted into
29
+ // secrets.jsonc" names nothing the reader can open — and a project whose name collides with
30
+ // another's is only visible from the whole path.
31
+ if (report.minted.length > 0) {
32
+ lines.push(`Minted ${list(report.minted)} into ${report.path ?? "the dev secrets file"}. Local only.`);
33
+ }
34
+ // Before the mints and the seeds, because it is about bytes that were already there. An adopter
35
+ // reading this block wants to know what changed in the file they maintain before what was added to it.
36
+ if ((report.migrated ?? []).length > 0) {
37
+ lines.push(
38
+ `Restated ${list(report.migrated ?? [])} in ${report.path ?? "the dev secrets file"}: the file states the value its destination receives.`,
39
+ );
40
+ }
41
+ if (report.seeded.length > 0) {
42
+ lines.push(`Seeded ${list(report.seeded)} into the local secrets store.`);
43
+ }
44
+ for (const { worker, reason } of report.skipped) {
45
+ lines.push(`${worker}: secrets not seeded. ${reason}`);
46
+ }
47
+ // These describe a `cf-secrets-store` value that is in the file and still will not reach a Worker —
48
+ // its binding is the only place it is ever read from. They are run outcomes, not standing states: a
49
+ // project with none hears nothing, and a project with any hears it every run until it is fixed.
50
+ lines.push(
51
+ ...renderDevVarsNotes({
52
+ refused: report.devVarsRefused ?? [],
53
+ ...(report.relinked !== undefined ? { relinked: report.relinked } : {}),
54
+ }),
55
+ );
56
+ return lines;
57
+ }
58
+
59
+ /** The two ways a generated `.dev.vars` is worth a sentence. Structurally a {@link WriteDevVarsResult}. */
60
+ export interface DevVarsDelivery {
61
+ /**
62
+ * One sentence per value or Worker directory that did not get one — a value no quoting survives, a
63
+ * `.dev.vars` pithy did not generate, a directory it may not write into. Already actionable.
64
+ */
65
+ refused: readonly string[];
66
+ /** Worker directories whose `.dev.vars` was a symlink from the old shared-file design, now a real file. */
67
+ relinked?: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * What one `.dev.vars` generation says out loud.
72
+ *
73
+ * **Shared, because a caller that reads only `refused` puts the defect back.** `writeDevVars` grew a
74
+ * delivery report to end a run claiming a value had arrived when it had not; `pithy add`'s two direct
75
+ * calls then took `.refused` off the result and dropped the rest, so `pithy add secrets` printed "Minted
76
+ * a dev master key" while the Worker answered `Missing required bindings`. One renderer means the next
77
+ * caller gets every list by taking the only thing there is to take.
78
+ *
79
+ * **Silence for the ordinary run.** `generated` and `unchanged` say nothing: a file rewritten with the
80
+ * same three bindings on every `pithy dev` is not news, and a line per Worker per start is how a block
81
+ * stops being read.
82
+ *
83
+ * A value never appears here. Names and directories only — these lines reach a terminal scrollback.
84
+ */
85
+ export function renderDevVarsNotes(delivery: DevVarsDelivery): string[] {
86
+ const lines = [...delivery.refused];
87
+ for (const dir of delivery.relinked ?? []) {
88
+ lines.push(
89
+ `${dir}/.dev.vars was a symlink at the project's shared file. It is a generated file now — put anything you kept in that shared file into .dev.vars.local.`,
90
+ );
91
+ }
92
+ return lines;
93
+ }
94
+
95
+ /** `a`, `a and b`, `a, b and c` — a sentence, not a JSON array. */
96
+ function list(names: readonly string[]): string {
97
+ if (names.length <= 1) return names.join("");
98
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
99
+ }
@@ -0,0 +1,344 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { resolve } from "node:path";
5
+ import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
7
+ import { migrateDevSecrets, mintMissingDevSecrets, seedDevSecrets } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
8
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
9
+ import type { StatePathOptions } from "../notifier/state";
10
+ import { writeDevVars } from "./devVars";
11
+ import { readDevSecrets, writeDevSecrets } from "./file";
12
+ import { resolveDevSecretsFile } from "./location";
13
+ import {
14
+ type DevSecretsStoreHandle,
15
+ localDevStorePath,
16
+ type OpenDevSecretsStoreOptions,
17
+ openDevSecretsStore,
18
+ } from "./store";
19
+ import { type DevSecretsTarget, mergedSecretRegistry, resolveDevSecretsTargets } from "./targets";
20
+
21
+ /**
22
+ * `pithy seed`'s dev-secrets half: take `<config>/<project>/secrets.jsonc`, mint what is missing and
23
+ * generatable, and put every declared value where its registry entry says it belongs. The idempotent
24
+ * workhorse `pithy add` and `pithy dev` both call, so there is one seeding path and not three that drift.
25
+ *
26
+ * **The input moved out of the checkout; the destinations did not (#156).** The file is resolved from
27
+ * the project's *name*, so every worktree of one project seeds from one file with no setup step — and
28
+ * the local `SECRETS` store, `.dev.vars`, and `.wrangler/state` are all still the project directory's.
29
+ *
30
+ * **The registry decides the destination, per Worker.** Capabilities are per-Worker, so a registry is
31
+ * too, and a `d1` secret goes into *that* Worker's local `SECRETS` store. The file is project-wide,
32
+ * because a secret name is the join key everywhere and two Workers sharing a name share a value.
33
+ *
34
+ * **Nothing here is fatal for a project that has no secrets.** A Worker that never composed the
35
+ * capability contributes no registry; one whose store cannot be opened contributes a reason. Both come
36
+ * back in the report for the caller to print. `pithy dev` must start a project whose secrets are not
37
+ * wired yet — refusing to would make an unrelated capability's missing binding stop every Worker.
38
+ *
39
+ * **`.dev.vars` no longer carries application secrets (#153).** #149 had to write every seeded value
40
+ * there as well, because `secretsStore`'s dev branch resolved every secret from its injected binding
41
+ * whatever its backend — so a `d1` value that only reached the local `SECRETS` store reached nowhere dev
42
+ * looked. Dev now routes by backend exactly as deployed does and reads the row this seeder writes, so the
43
+ * dual-write is gone and `.dev.vars` is back to what wrangler says it is: env bindings, `UPPER_SNAKE`,
44
+ * one namespace. The only thing still written there is a `cf-secrets-store` secret, which belongs there
45
+ * permanently — there is no local Secrets Store, and the binding is the only place a Worker can read it.
46
+ *
47
+ * **A registry secret sitting in an adopter's `.dev.vars` is now inert, and this run treats it as
48
+ * absent.** It used to be dev's live value, so minting beside it produced two values with nothing to say
49
+ * which signed what. It signs nothing now. So a mintable secret is minted and seeded — a project that
50
+ * upgrades is not left with a Worker that cannot resolve its session key — and the stranded line is
51
+ * `pithy doctor`'s to name, every run, until it is deleted. Nothing here rewrites their file.
52
+ *
53
+ * **The `cf-secrets-store` write goes through {@link writeDevVars}, which is what makes it arrive.**
54
+ * Writing the project root's `.dev.vars` was never the same as reaching the Worker: `pithy dev` runs
55
+ * wrangler with `cwd: apps/<worker>`, and wrangler loads the file beside the Worker's own config. Each
56
+ * Worker's file is **generated** now (#154), from the machine-local bootstrap store and the
57
+ * `.dev.vars.local` overrides — so there is no link to wire, dangle, delete, or detach, and this seeding
58
+ * run is one of the two commands that regenerate.
59
+ */
60
+
61
+ /** What one seeding run did. Every list is sorted, so two runs of the same state read the same. */
62
+ export interface DevSecretsSeedReport {
63
+ /**
64
+ * The absolute path of the secrets file this run read and minted into.
65
+ *
66
+ * In the report because the file is outside the checkout (#156) and nothing else in the run names
67
+ * it: "minted a dev auth-session-secret" is not actionable if the reader cannot open what it landed
68
+ * in. Optional so report doubles that assert only on `seeded` or `skipped` stay valid.
69
+ */
70
+ path?: string;
71
+ /** `d1` secrets written this run — new, or changed in the file since the last run. */
72
+ seeded: string[];
73
+ /** `d1` secrets already stored with the value the file states. Not rewritten. */
74
+ unchanged: string[];
75
+ /** Values minted this run and written back into the secrets file. */
76
+ minted: string[];
77
+ /**
78
+ * Secrets whose entry this run **restated as the payload its destination receives** (#323) — the
79
+ * upgrade off the old wrapped shape, performed in place so no operator hand-edits a master key.
80
+ *
81
+ * Empty on every run after the first, and on every project created since. Never silent while it is
82
+ * not: the bytes of the one file an adopter hand-maintains changed, and a rewrite nobody is told
83
+ * about is indistinguishable from corruption the next time they open it.
84
+ *
85
+ * Optional for the same reason {@link DevSecretsSeedReport.path} is: a report double that asserts
86
+ * only on `seeded` or `skipped` stays valid.
87
+ */
88
+ migrated?: string[];
89
+ /**
90
+ * Secrets written into `.dev.vars` this run — `cf-secrets-store` ones, and only those. There is no
91
+ * local Secrets Store, so the binding is the only place a Worker can read one from. A `d1` secret is
92
+ * never here: since #153 dev reads its seeded row, the same as deployed.
93
+ */
94
+ devVars: string[];
95
+ /** Declared secrets with no value and nothing honest to mint. The adopter supplies these. */
96
+ missing: string[];
97
+ /** Names in the file that no Worker's registry declares. Reported, never fatal. */
98
+ undeclared: string[];
99
+ /** Workers whose local store could not be opened, and the one thing each needs. */
100
+ skipped: { worker: string; reason: string }[];
101
+ /**
102
+ * One sentence per value no `.dev.vars` quoting survives — see `encodeDevVarsValue`. Never a value.
103
+ * Only a `cf-secrets-store` secret can be refused now, and the binding is the only place that one is
104
+ * ever read from, so a refusal is a Worker without it. Any superseded line went with it, so the
105
+ * sentence describes a Worker with *no* value rather than one quietly on the old one.
106
+ */
107
+ devVarsRefused?: string[];
108
+ /**
109
+ * Worker directories whose `.dev.vars` was a symlink from the old shared-file design and is now a
110
+ * generated file (#154). A link holds no content, so nothing was lost — but which secrets a Worker
111
+ * runs with did change, so it is never silent.
112
+ */
113
+ relinked?: string[];
114
+ }
115
+
116
+ /** What {@link seedProjectDevSecrets} needs. Both seams default to the real project. */
117
+ export interface SeedProjectDevSecretsOptions {
118
+ /** The project root — owner of `.dev.vars` and the `.wrangler/state` stores. Not of the secrets file. */
119
+ projectDir: string;
120
+ /**
121
+ * Where the Pithy config directory is, for {@link resolveDevSecretsFile}. Defaults to the real one:
122
+ * `$PITHY_CONFIG_DIR`, else the platform's. A seam so a test never writes to the operator's own file.
123
+ */
124
+ paths?: StatePathOptions;
125
+ /** The Workers to seed for. Defaults to every Worker in `apps/` that composes the secrets capability. */
126
+ targets?: DevSecretsTarget[];
127
+ /** Seam: open one Worker's local store. Defaults to the real Miniflare-backed one. */
128
+ openStore?: (options: OpenDevSecretsStoreOptions) => Promise<DevSecretsStoreHandle>;
129
+ /**
130
+ * Re-import every `pithy.config.ts` before deriving targets — see {@link DevSecretsTargetsOptions}.
131
+ * Ignored when `targets` is supplied, which is already an answer about the composition.
132
+ */
133
+ reload?: boolean;
134
+ /**
135
+ * **Unsayable on purpose (#159).** No environment, ever, by any spelling.
136
+ *
137
+ * The dev secrets file holds minted random dev values. Seeding it into staging or production would not
138
+ * set some secrets — it would rotate every one at once: every session invalidated, every signed link
139
+ * broken, every OAuth credential replaced with a value the provider has never seen, and no undo,
140
+ * because the values it overwrote were the only copies. A `--force` does not make that safe, it makes
141
+ * it reachable. Production secrets are set one at a time by `pithy secrets provision` and
142
+ * `pithy secrets set`, which know they are touching a live environment.
143
+ *
144
+ * `never` is the strong half of the guarantee: a caller cannot pass the wrong environment because it
145
+ * cannot pass one at all. {@link assertLocalDevStore} is the other half, for the destination a caller
146
+ * *can* still get wrong.
147
+ */
148
+ env?: never;
149
+ }
150
+
151
+ /** Seed the project's dev secrets. Throws only when the file itself is malformed — that is the boundary. */
152
+ export async function seedProjectDevSecrets(options: SeedProjectDevSecretsOptions): Promise<DevSecretsSeedReport> {
153
+ const projectDir = options.projectDir;
154
+ const openStore = options.openStore ?? openDevSecretsStore;
155
+ // Resolved through the form that cannot drop a failure (#199). The unresolvable half is deliberately
156
+ // not re-reported from here: `pithy seed` fails outright on an unloadable config long before it
157
+ // reaches this function, and inside `pithy dev` the generation step has already said it in one
158
+ // sentence. Saying it twice, in two blocks, is the correlation problem the sentence exists to end.
159
+ const targets =
160
+ options.targets ?? (await resolveDevSecretsTargets(projectDir, { reload: options.reload === true })).targets;
161
+
162
+ const seeded = new Set<string>();
163
+ const unchanged = new Set<string>();
164
+ const missing = new Set<string>();
165
+ const skipped: { worker: string; reason: string }[] = [];
166
+ const declared = new Set<string>();
167
+ // Resolved once, from the project's name rather than its directory (#156) — so every worktree of one
168
+ // project seeds from one file, with no setup step and nothing linked into the checkout. It is also
169
+ // what every note and every error this run raises names, because nothing in the project points at it.
170
+ const path = await resolveDevSecretsFile(projectDir, options.paths ?? {});
171
+ // The file is read once and carried across Workers. Two Workers that declare one secret must mint it
172
+ // once: the second sees the first's value in this object, and `seedDevSecrets` never mints over one.
173
+ const file = await readDevSecrets(path);
174
+ // **Before a value is read, minted or seeded.** The reader accepts both shapes, so nothing depends on
175
+ // this having run — that is what makes it safe to do here rather than in a migration command nobody
176
+ // runs. What it buys is that the file stops holding a shape the writer no longer produces, which is
177
+ // the state two readers reported as corruption (#323).
178
+ const migrated = await restatePayloads(path, file, targets);
179
+
180
+ const minted = new Set<string>();
181
+
182
+ for (const target of targets) {
183
+ for (const name of Object.keys(target.registry)) declared.add(name);
184
+
185
+ const handle = await openStore({
186
+ projectDir,
187
+ workerDir: target.dir,
188
+ worker: target.name,
189
+ ...(options.paths !== undefined ? { paths: options.paths } : {}),
190
+ });
191
+ if (!handle.ready) {
192
+ skipped.push({ worker: target.name, reason: handle.reason });
193
+ continue;
194
+ }
195
+ try {
196
+ // Before a byte is minted or stored. The destination is what makes this a dev seeding run — not
197
+ // the caller's word for it, and not a flag anywhere upstream.
198
+ assertLocalDevStore(projectDir, target.name, handle.persistPath);
199
+ // **Persist before storing.** A minted value written to D1 before it reaches the secrets
200
+ // file is a row nothing explains: the next run finds the file still without it,
201
+ // mints a *different* value, and overwrites the row — for a session secret, every live session
202
+ // invalidated on every `pithy dev`, for as long as the file write keeps failing. And a failing
203
+ // file write is exactly the state that produced it. This way a failed write costs a value that
204
+ // never existed anywhere, and the store is left holding the last one that did.
205
+ const fresh = mintMissingDevSecrets(file, target.registry);
206
+ for (const name of await writeDevSecrets(path, fresh)) {
207
+ const envelope = fresh[name];
208
+ if (!envelope) continue;
209
+ file[name] = envelope;
210
+ minted.add(name);
211
+ }
212
+
213
+ // Nothing minted here is seeded unless it landed. Both `file` and the registry are narrowed, so
214
+ // `seedDevSecrets` has nothing left to mint and this is the only place a mint can happen.
215
+ const result = await seedDevSecrets({
216
+ file,
217
+ registry: seedable(target.registry, file),
218
+ store: handle.store,
219
+ path,
220
+ });
221
+ for (const name of result.seeded) seeded.add(name);
222
+ for (const name of result.unchanged) unchanged.add(name);
223
+ for (const name of result.missing) missing.add(name);
224
+ // `result.devVars` is deliberately not consumed here any more (#179). A `cf-secrets-store` value's
225
+ // destination is the generated `.dev.vars`, and the generator reads `secrets.jsonc` itself — the
226
+ // same file this loop has open. Copying it out through this run was what made the dev secrets file
227
+ // stop being the source of the values a Worker receives.
228
+ } finally {
229
+ await handle.dispose();
230
+ }
231
+ }
232
+
233
+ // Regenerate every Worker's `.dev.vars` from the sources, carrying **these** targets — the composition
234
+ // this run resolved, which for `pithy add` is the reloaded one. Nothing is recorded on the way: the
235
+ // values are in `secrets.jsonc`, which the generator reads.
236
+ //
237
+ // It runs even when there was nothing to seed, which is what makes a fresh clone's `pithy dev` work
238
+ // with no postinstall and nothing to remember (#139, closed by removal).
239
+ const wrote = await writeDevVars({
240
+ projectDir,
241
+ values: {},
242
+ targets,
243
+ ...(options.paths !== undefined ? { paths: options.paths } : {}),
244
+ });
245
+
246
+ // No target is no registry, and no registry is nothing to judge a name against. Calling every secret
247
+ // in the file undeclared because this project has not composed `secrets` yet is a false statement, and
248
+ // `pithy add auth` made it about the value it had just minted itself.
249
+ const undeclared = targets.length === 0 ? [] : Object.keys(file).filter((name) => !declared.has(name));
250
+ return {
251
+ path,
252
+ seeded: sorted(seeded),
253
+ unchanged: sorted(unchanged),
254
+ // What the write actually landed, never what was minted into memory. A refused write minted values
255
+ // that reached no file, and reporting them as minted is how a command claims a value it does not have.
256
+ minted: sorted(minted),
257
+ migrated,
258
+ // What the generated files actually carry, never what was handed to a writer — a value no quoting
259
+ // survives is refused, and reporting it as written is how a command claims a binding the Worker does
260
+ // not have. Narrowed to this project's `cf-secrets-store` secrets, which is what this field means.
261
+ devVars: wrote.names.filter((name) => isBindingSecret(name, targets)),
262
+ // A secret one Worker cannot mint may be another's to seed. Only the ones nothing supplied are missing.
263
+ missing: sorted(missing).filter((name) => !seeded.has(name) && !unchanged.has(name)),
264
+ undeclared: undeclared.sort(),
265
+ skipped,
266
+ devVarsRefused: wrote.refused,
267
+ relinked: wrote.relinked,
268
+ };
269
+ }
270
+
271
+ /** Whether any target's registry declares `name` as a secret a Worker reads from a `.dev.vars` binding. */
272
+ function isBindingSecret(name: string, targets: readonly DevSecretsTarget[]): boolean {
273
+ return targets.some(
274
+ (target) => Object.hasOwn(target.registry, name) && target.registry[name]?.backend === "cf-secrets-store",
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Refuse any store that is not this project's own local dev store (#159).
280
+ *
281
+ * **The rule lives here rather than at a call site.** `commands/seed.ts` has guarded it correctly since
282
+ * the day it was written — `env === "dev" && !dryRun` — and that is one caller out of six. Four defect
283
+ * classes in this branch each had three or more producers, every one because the rule was enforced where
284
+ * the thing was called instead of inside the thing being called. This one's payload is every live secret
285
+ * in a production environment, rotated at once, with no copy of what it overwrote.
286
+ *
287
+ * **The destination is asserted, not the intent.** A parameter saying `dev` is a claim; where the rows
288
+ * land is a fact. `openDevSecretsStore` opens Miniflare over {@link localDevStorePath} and reports that
289
+ * path, so a handle bound to a remote D1 — through the `openStore` seam, or a future one — cannot pass.
290
+ *
291
+ * **And an unresolvable destination refuses.** A handle with no path at all is not "probably fine": the
292
+ * permissive default is the whole bug this closes. `undefined` is in the signature and not in the type
293
+ * because the type already forbids it — this is what answers a caller that came from outside TypeScript.
294
+ */
295
+ function assertLocalDevStore(projectDir: string, worker: string, persistPath: string | undefined): void {
296
+ const expected = localDevStorePath(projectDir);
297
+ if (persistPath !== undefined && resolve(persistPath) === resolve(expected)) return;
298
+ throw new ConflictError({
299
+ message: `Refusing to seed dev secrets for ${worker}: that is not this project's local dev store.`,
300
+ action:
301
+ "Dev secrets are local only. A deployed environment gets its secrets from pithy secrets provision and pithy secrets set, one at a time.",
302
+ detail: `expected the local dev store at '${expected}'; the handle named ${persistPath === undefined ? "no path at all" : `'${persistPath}'`}`,
303
+ });
304
+ }
305
+
306
+ /**
307
+ * The registry minus every mintable secret the file still does not carry — the entries whose write was
308
+ * refused. Dropping them is what stops `seedDevSecrets` from minting a second value and storing it: a
309
+ * row whose value exists in no file is the one outcome minting-before-persisting produced.
310
+ *
311
+ * A no-op on every ordinary run, where the write landed and every mintable name is in the file.
312
+ */
313
+ function seedable(registry: SecretRegistry, file: DevSecretsFile): SecretRegistry {
314
+ const entries = Object.entries(registry).filter(
315
+ ([name, entry]) => !entry.devValue || entry.keyed || Object.hasOwn(file, name),
316
+ );
317
+ return Object.fromEntries(entries) as SecretRegistry;
318
+ }
319
+
320
+ /**
321
+ * Rewrite every entry the file still states in the old wrapped shape, and answer which ones moved.
322
+ *
323
+ * **In place, and without an operator hand-editing anything (#323).** The one file an adopter maintains
324
+ * holds their master key; "open it and take a layer off" is an instruction with a way to get it wrong,
325
+ * on the value whose loss orphans every secret encrypted under it.
326
+ *
327
+ * `replace: true` because the name is already there — this is the one write that is meant to change a
328
+ * value already in the file, and it changes only its shape. An entry whose bytes would not change
329
+ * writes nothing, so a migrated project's `pithy dev` rewrites no file and reports no migration.
330
+ */
331
+ async function restatePayloads(path: string, file: DevSecretsFile, targets: DevSecretsTarget[]): Promise<string[]> {
332
+ const restated = migrateDevSecrets(file, mergedSecretRegistry(targets));
333
+ if (Object.keys(restated).length === 0) return [];
334
+ const wrote = await writeDevSecrets(path, restated, { replace: true });
335
+ // Only what landed. The in-memory file is what the rest of this run seeds from, and carrying a value
336
+ // the write refused would seed a shape no file explains — the same rule the mint above follows.
337
+ for (const name of wrote) file[name] = restated[name];
338
+ return wrote.sort();
339
+ }
340
+
341
+ /** A set as a sorted array — every list in the report is ordered, so a run reads the same twice. */
342
+ function sorted(names: Set<string>): string[] {
343
+ return [...names].sort();
344
+ }