@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,137 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ResolvedSeedSet } from "@pithy-sh/core/src/seed/compose";
5
+ import type { MediaSeedItem } from "@pithy-sh/core/src/seed/seed";
6
+
7
+ /**
8
+ * The `--dry-run` write plan: what `pithy seed` **would** write, computed from the composed sets alone
9
+ * with no mutation. Emitted as `--json` so a human or an agent can review a run — especially against
10
+ * staging/prod — before authorizing it. Row and entry counts come straight from the fixtures;
11
+ * per-asset media actions come from an optional resolver (the CLI passes one that reads each item's
12
+ * sidecar), defaulting to the action a first run would take.
13
+ */
14
+
15
+ /** One table's line in the plan: which table in which database, and how many rows would be written. */
16
+ export interface SeedPlanD1Entry {
17
+ /** The named database the table lives in. */
18
+ database: string;
19
+ /** The table that would be seeded. */
20
+ table: string;
21
+ /** The number of rows the fixture would write (idempotent — existing rows are ignored). */
22
+ rows: number;
23
+ }
24
+
25
+ /** One store's line in the plan: which store in which namespace, and how many entries would be written. */
26
+ export interface SeedPlanKvEntry {
27
+ /** The named KV namespace the store lives in. */
28
+ namespace: string;
29
+ /** The store that would be seeded. */
30
+ store: string;
31
+ /** The number of entries the fixture would write. */
32
+ entries: number;
33
+ }
34
+
35
+ /** One object's line in the plan: the bucket binding and key that would be written. */
36
+ export interface SeedPlanR2Entry {
37
+ /** The R2 binding the object would be written to. */
38
+ binding: string;
39
+ /** The object key. */
40
+ key: string;
41
+ }
42
+
43
+ /**
44
+ * What a media item would do on this run: `upload` (a `once` asset with no recorded UUID yet),
45
+ * `skip` (a `once` asset already uploaded — its UUID is on record), or `reupload` (an `always` asset,
46
+ * re-sent every run).
47
+ */
48
+ export type SeedPlanMediaAction = "upload" | "skip" | "reupload";
49
+
50
+ /** One asset's line in the plan: the store, its mode, the action, and the recorded UUID if any. */
51
+ export interface SeedPlanMediaEntry {
52
+ /** The shared asset store the item targets. */
53
+ store: MediaSeedItem["store"];
54
+ /** The item's upload policy. */
55
+ mode: MediaSeedItem["mode"];
56
+ /** What this run would do for the item. */
57
+ action: SeedPlanMediaAction;
58
+ /** The already-minted asset UUID, when the sidecar records one (a `skip`). */
59
+ id?: string;
60
+ }
61
+
62
+ /** One set's slice of the plan: its namespaced name and the writes it would make, per backend. */
63
+ export interface SeedPlanSet {
64
+ /** The set's namespaced, order-prefixed key (`NNNN_<capability>_<name>`). */
65
+ name: string;
66
+ /** The D1 tables this set would write. */
67
+ d1: SeedPlanD1Entry[];
68
+ /** The KV stores this set would write. */
69
+ kv: SeedPlanKvEntry[];
70
+ /** The R2 objects this set would write. */
71
+ r2: SeedPlanR2Entry[];
72
+ /** The media assets this set would upload. */
73
+ media: SeedPlanMediaEntry[];
74
+ }
75
+
76
+ /** The complete dry-run plan for one environment. Serialized verbatim as the `--json` output. */
77
+ export interface SeedPlan {
78
+ /** The command that produced the plan. */
79
+ command: "seed";
80
+ /** The environment the plan targets. */
81
+ env: string;
82
+ /** Always `true` — a plan never writes. */
83
+ dryRun: true;
84
+ /** The per-set plan, in run order. */
85
+ sets: SeedPlanSet[];
86
+ }
87
+
88
+ /**
89
+ * Resolve what a media item would do this run. The CLI passes an implementation that reads the item's
90
+ * `ref` sidecar; the default assumes a first run (a `once` item uploads, an `always` item re-uploads).
91
+ */
92
+ export type MediaStateResolver = (item: MediaSeedItem) => { action: SeedPlanMediaAction; id?: string };
93
+
94
+ /** The default media state: a first run — `once` uploads, `always` re-uploads, nothing recorded yet. */
95
+ const firstRunMediaState: MediaStateResolver = (item) => ({
96
+ action: item.mode === "always" ? "reupload" : "upload",
97
+ });
98
+
99
+ /**
100
+ * Assemble the dry-run write plan from the composed, ordered sets — pure, with no backend access. Row
101
+ * and entry counts are read from the fixtures; media actions come from `resolveMedia` (default: a
102
+ * first-run state). The result is the exact shape `pithy seed --dry-run --json` prints.
103
+ */
104
+ export function buildDryRunPlan(
105
+ env: string,
106
+ sets: readonly ResolvedSeedSet[],
107
+ resolveMedia: MediaStateResolver = firstRunMediaState,
108
+ ): SeedPlan {
109
+ return {
110
+ command: "seed",
111
+ env,
112
+ dryRun: true,
113
+ sets: sets.map((resolved) => ({
114
+ name: resolved.key,
115
+ d1: (resolved.set.d1 ?? []).map((group) => ({
116
+ database: group.database,
117
+ table: group.table,
118
+ rows: group.rows.length,
119
+ })),
120
+ kv: (resolved.set.kv ?? []).map((group) => ({
121
+ namespace: group.namespace,
122
+ store: group.store,
123
+ entries: group.entries.length,
124
+ })),
125
+ r2: (resolved.set.r2 ?? []).map((item) => ({ binding: item.binding, key: item.key })),
126
+ media: (resolved.set.media ?? []).map((item) => {
127
+ const state = resolveMedia(item);
128
+ return {
129
+ store: item.store,
130
+ mode: item.mode,
131
+ action: state.action,
132
+ ...(state.id !== undefined ? { id: state.id } : {}),
133
+ };
134
+ }),
135
+ })),
136
+ };
137
+ }
@@ -0,0 +1,224 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import { basename, join } from "node:path";
6
+ import { ConflictError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { SEED_ARTIFACT_DIR } from "@pithy-sh/core/src/seed/devLogin";
8
+ import type { SeedArtifact } from "@pithy-sh/core/src/seed/seed";
9
+ import { currentValue } from "@pithy-sh/secrets/src/crypto/versionedValue";
10
+ import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
11
+ import { storedSecretValue } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
12
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
13
+ import { readDevSecrets } from "../devSecrets/file";
14
+ import { devSecretsFile } from "../devSecrets/location";
15
+ import { projectConfigDir, type StatePathOptions, stateDir } from "../notifier/state";
16
+ import { writeFileAtomic } from "../project/atomic";
17
+
18
+ /**
19
+ * The CLI half of the prepared-set seam: everything a `SeedSet.prepare` hook needs from the machine, kept
20
+ * here because a capability module is bundled into the Worker and cannot reach the filesystem at all.
21
+ * The hook gets values and callbacks; the disk stays on this side.
22
+ */
23
+
24
+ /** The file's name inside the project's config directory. Undotted: nothing here is hidden from anything. */
25
+ export const DEV_PREFERENCES_FILE_NAME = "dev.json";
26
+
27
+ /**
28
+ * Where a developer states their machine-local preferences for one project: `<stateDir()>/<project>/dev.json`
29
+ * — `%APPDATA%\pithy\<project>\dev.json` on Windows, `$XDG_CONFIG_HOME/pithy/<project>/dev.json` when that
30
+ * is set, else `~/.config/pithy/<project>/dev.json`.
31
+ *
32
+ * Outside the repo on purpose. A machine opts itself in with no commit and no per-run flag, and two
33
+ * developers sharing one checkout can be different people. The project segment stays for the same reason
34
+ * one level down: a developer with two Pithy projects checked out wants to be a different user in each.
35
+ *
36
+ * **Under Pithy's own config directory, not straight under the config root.** This used to resolve
37
+ * `$XDG_CONFIG_HOME/<project>/dev.json`, which squatted on a namespace the CLI does not own: a Pithy project
38
+ * name is short and generic by design — `dash`, `api`, `web` — so `~/.config/dash/` is a plausible collision
39
+ * with an unrelated program, and the loser is whichever wrote second.
40
+ *
41
+ * **Resolved through {@link stateDir} rather than by hand.** The hand-rolled version had no `win32` branch
42
+ * at all, so a Windows developer's file landed where nothing reads it while the resolver three files away
43
+ * got it right. Delegating deletes that logic instead of adding a third copy of it, and it makes this the
44
+ * directory `pithy doctor` already reports — which is what lets doctor name this file at all.
45
+ *
46
+ * One behavioral consequence, taken deliberately: `stateDir` reads the home directory from `os.homedir()`,
47
+ * not from `$HOME`, so exporting `HOME` no longer relocates the preference file. That is the same rule the
48
+ * state file has always followed, and one rule is the point.
49
+ *
50
+ * **The project segment is {@link projectConfigDir}'s to join, and its rule to state (#212).** This
51
+ * function used to join the name itself, with the rule that it was safe to put in a path living at every
52
+ * call site — safe every time, because each caller had been through `requireProjectName`, and safe by a
53
+ * property of the call graph rather than of the value.
54
+ */
55
+ export function devPreferencesPath(project: string, options: StatePathOptions = {}): string {
56
+ return join(projectConfigDir(project, options), DEV_PREFERENCES_FILE_NAME);
57
+ }
58
+
59
+ /**
60
+ * Read {@link devPreferencesPath}, parsed but unvalidated — the set that consumes it owns its shape.
61
+ *
62
+ * An absent file is the default (and means "seed nothing extra"), so it is `undefined`, not an error. So is
63
+ * an unparseable one: this file is hand-edited, and a half-typed preference should not fail a whole seed
64
+ * run. A file that parses but says the wrong thing is a different matter, and the set rejects it loudly.
65
+ */
66
+ export async function readDevPreferences(project: string, options: StatePathOptions = {}): Promise<unknown> {
67
+ try {
68
+ return JSON.parse(await readFile(devPreferencesPath(project, options), "utf8"));
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The one environment whose secrets are on the operator's disk. Everything else is a managed environment
76
+ * and gets its secrets from its own store — see {@link devSecretReader}.
77
+ */
78
+ const LOCAL_ENVIRONMENT = "dev";
79
+
80
+ /** What a prepared set needs to build a dev-only reader: the project, its registry, and the environment. */
81
+ export interface DevSecretReaderOptions {
82
+ /**
83
+ * The project's **name** — what resolves the dev secrets file, exactly as it resolves `dev.json`
84
+ * one directory over. Not the project root: since #156 the file is at `<config>/<project>/`, so a
85
+ * directory is no longer an answer to where a secret is.
86
+ */
87
+ project: string;
88
+ /**
89
+ * The environment this seed run is writing to. **Required, and that is the structural half of the rule
90
+ * (#159).** A caller cannot build a dev-secrets reader without stating where the rows are going, so
91
+ * there is no signature left that reads dev secrets for an environment nobody named.
92
+ */
93
+ env: string;
94
+ /**
95
+ * The project's aggregate secret registry — the authority on whether a name is a secret at all, and
96
+ * on the shape its value must have. Required for the same reason `env` is: a reader that guesses
97
+ * either one is a reader nobody can audit.
98
+ */
99
+ registry: SecretRegistry;
100
+ /** Where the Pithy config directory is. Defaults to the real one — a seam, so a test reads its own. */
101
+ paths?: StatePathOptions;
102
+ }
103
+
104
+ /**
105
+ * Read a named secret out of the project's **dev secrets file** — **in `dev`, and in no other environment.**
106
+ *
107
+ * That is where local dev's secrets live: `<config>/<project>/secrets.jsonc`, the same file `pithy seed`
108
+ * has just minted into and seeded from, two steps before a prepared set runs. It needs no D1 and no
109
+ * master key, so it answers at the point in the pipeline where seeds happen.
110
+ *
111
+ * **This read `.dev.vars` until #176, and that was the whole defect.** #153 moved every `d1` secret out
112
+ * of that file and #154 made each Worker's copy a generated artifact; the reader was not moved with
113
+ * them. So `pithy seed` on a project composing auth's dev-session seed printed two lines that
114
+ * contradicted each other — the secret was seeded, and the seed that needs it could not see it — and
115
+ * the fix it suggested was to undo #153.
116
+ *
117
+ * **One source, whatever the backend, and so no branch.** `.dev.vars` is not the other half of this
118
+ * question any more: it is a *destination* now, generated per Worker from this same file, and a
119
+ * `cf-secrets-store` value written there is this file's value encoded for a binding. Reading it back
120
+ * would be reading a copy — and a run-wide reader cannot even say *which* Worker's copy, because since
121
+ * #154 there are as many as there are Workers. A `cf-secrets-store` secret therefore resolves here too,
122
+ * from the one place its dev value is stated. What is given up is narrow and deliberate: a value
123
+ * supplied only through a hand-written `.dev.vars.local` override is not visible to a prepared set. It
124
+ * is an override of what the Worker reads, not a statement of what the secret is.
125
+ *
126
+ * **Through the registry, and through `storedSecretValue`, so this is the value the Worker gets.** The
127
+ * registry says whether a `text` secret's version really is a string and validates a `json` one against
128
+ * its schema; the conversion is the same one the seeder uses to fill the store. So a prepared set is
129
+ * handed exactly the bytes `secretsStore` will resolve at runtime — not a near-miss parsed a second way
130
+ * by a second reader. A name the registry does not declare answers `undefined`: nothing declares it, so
131
+ * the running Worker cannot resolve it either. A keyspace answers `undefined` for the same reason
132
+ * `get(name)` refuses one — it has no single value.
133
+ *
134
+ * **The environment gate is structural, not a caller's courtesy (#159).** An older doc said a deployed
135
+ * environment's secrets are not on the operator's disk, so a read would answer `undefined` there. That
136
+ * was never true of the operator's *own* machine: the dev secrets file sits under `~/.config` whatever
137
+ * `--env` says, so `pithy seed --env prod` handed a prepared set a live local dev secret and wrote it
138
+ * into production rows. #159's rule is absolute and the adopter cannot opt out — dev secrets never reach
139
+ * a managed environment — and a reader that leaks them is the same hole as a writer that plants them. So
140
+ * outside `dev` the reading closure is never built: the caller gets a reader that refuses, and the file
141
+ * is never opened at all.
142
+ *
143
+ * **Provably dev, not merely not-prod.** An unknown, misspelled, or empty environment refuses too. The
144
+ * permissive default is the entire bug.
145
+ *
146
+ * **Read at most once per run.** The file is hand-edited, and two Workers in one fan-out must not observe
147
+ * it in two states — the same rule, for the same reason, that `preparedRun` applies to `dev.json`. A run
148
+ * with no prepared set never opens it at all. And an absent file is `{}` while an unreadable one throws:
149
+ * `readDevSecrets` owns that distinction, because answering "empty" for `EACCES` is how a set writes a
150
+ * row against a secret it never got.
151
+ */
152
+ export function devSecretReader(options: DevSecretReaderOptions): (name: string) => Promise<string | undefined> {
153
+ const path = devSecretsFile(options.project, options.paths ?? {});
154
+ if (options.env !== LOCAL_ENVIRONMENT) return refuseOutsideDev(options.env, path);
155
+ let pending: Promise<DevSecretsFile> | undefined;
156
+ return async (name: string) => {
157
+ // `Object.hasOwn`, never a bare index: a prepared set asking for `constructor` or `toString` would
158
+ // otherwise reach an `Object.prototype` member and be handed something that is not a secret at all.
159
+ if (!Object.hasOwn(options.registry, name)) return undefined;
160
+ const entry = options.registry[name];
161
+ if (!entry || entry.keyed) return undefined;
162
+ if (pending === undefined) pending = readDevSecrets(path);
163
+ const file = await pending;
164
+ const envelope = file[name];
165
+ if (!envelope) return undefined;
166
+ return currentValue(storedSecretValue(entry, name, envelope, path));
167
+ };
168
+ }
169
+
170
+ /**
171
+ * The reader a managed environment gets: one that refuses by name and reads nothing.
172
+ *
173
+ * It refuses when a set *asks*, not when the run starts — `pithy seed --env prod` is a legitimate command,
174
+ * and a set that never wants a secret is none of this rule's business. A set that does want one is a
175
+ * `dev`-only set, and this is where it finds that out, loudly, before a row is written.
176
+ *
177
+ * The secret's name is in the message because it is a registry key an adopter wrote; its value never is,
178
+ * in `message` or in `detail`, because this function has not read one and never will.
179
+ */
180
+ function refuseOutsideDev(env: string, path: string): (name: string) => Promise<string | undefined> {
181
+ return async (name: string) => {
182
+ throw new ConflictError({
183
+ message: `Refusing to read the dev secret "${name}" while seeding ${env}.`,
184
+ action:
185
+ "Dev secrets are local only. Mark this set dev-only, or set the value for that environment with pithy secrets set.",
186
+ detail: `devSecretReader refused ${path} for env "${env}"; only "${LOCAL_ENVIRONMENT}" resolves secrets from the dev secrets file`,
187
+ });
188
+ };
189
+ }
190
+
191
+ /**
192
+ * The mode a freshly written artifact lands with. `logs/dev-login.json` holds a **live session cookie** —
193
+ * one `cat` from being anybody's login — and the umask is not a permission policy.
194
+ */
195
+ const ARTIFACT_MODE = 0o600;
196
+
197
+ /**
198
+ * Write one prepared artifact under the project's `logs/`, returning the path written.
199
+ *
200
+ * The directory is not the fixture's to choose: `logs/` is gitignored by the starter template, and the one
201
+ * artifact that exists holds a live session cookie. A `file` carrying any directory part is refused rather
202
+ * than normalized — a fixture that tried it is a bug, and silently relocating it would hide the bug.
203
+ *
204
+ * **Through {@link writeFileAtomic}, for the same two reasons `.dev.vars` is.** A plain `writeFile` follows
205
+ * a symlink at the target wherever it points, so a foreign-owned link left at `logs/dev-login.json` carried
206
+ * a live session cookie out of the project; and it lands the file at whatever the umask allows, which for
207
+ * a credential is a decision nobody made. The primitive owns both rules — an ownership check on every link
208
+ * it follows, and a mode the file is *born* with rather than widened from. A file already there keeps its
209
+ * own mode: those permissions are the adopter's.
210
+ */
211
+ export async function writeSeedArtifact(projectDir: string, artifact: SeedArtifact): Promise<string> {
212
+ if (artifact.file !== basename(artifact.file) || artifact.file.startsWith(".")) {
213
+ throw new ValidationError({
214
+ message: "A seed artifact must be a plain file name.",
215
+ action: `Name the file itself; it is always written into ${SEED_ARTIFACT_DIR}/.`,
216
+ detail: `artifact file "${artifact.file}" is not a plain basename`,
217
+ });
218
+ }
219
+ const dir = join(projectDir, SEED_ARTIFACT_DIR);
220
+ await mkdir(dir, { recursive: true });
221
+ const path = join(dir, artifact.file);
222
+ await writeFileAtomic(path, artifact.contents, { mode: ARTIFACT_MODE });
223
+ return path;
224
+ }
@@ -0,0 +1,25 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { type ComposedSeeds, composeSeeds } from "@pithy-sh/core/src/seed/compose";
6
+
7
+ /** Options controlling how {@link buildSeedPlan} composes the project's seed sets. */
8
+ export interface BuildSeedPlanOptions {
9
+ /** The environment being seeded. A set runs only if its `environments` lists this value. */
10
+ env: string;
11
+ /** Whether `example` sets are composed in (the project's `seed.includeExamples`; default off). */
12
+ includeExamples: boolean;
13
+ }
14
+
15
+ /**
16
+ * Compose every capability's `seeds` into one ordered, env-filtered registry — the CLI's thin seam
17
+ * over core's `composeSeeds`, the peer of `buildRegistryFromCapabilities` for migrations. Returns the
18
+ * runnable sets (library-before-app by `order`, then namespaced key) plus the keys of sets present but
19
+ * disallowed for `env` (`skippedByEnv`), so the command can report a set it refused to run rather than
20
+ * silently doing nothing. Ordering, namespacing, example filtering, and the env allowlist all live in
21
+ * core; this wrapper is the single import site `pithy seed` composes through.
22
+ */
23
+ export function buildSeedPlan(capabilities: Capability[], options: BuildSeedPlanOptions): ComposedSeeds {
24
+ return composeSeeds(capabilities, { env: options.env, includeExamples: options.includeExamples });
25
+ }