@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,262 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { D1Database } from "@cloudflare/workers-types";
7
+ import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
8
+ import { sentenceOf } from "@pithy-sh/core/src/error/pithyError";
9
+ import { masterKeyRegistryEntry } from "@pithy-sh/secrets/src/capability";
10
+ import { type DevSecretsStore, devVarsForRegistry } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
11
+ import { MASTER_KEY_BINDING } from "@pithy-sh/secrets/src/env/bindings";
12
+ import { SystemSecretsStore } from "@pithy-sh/secrets/src/store/systemSecretsStore";
13
+ import { Miniflare } from "miniflare";
14
+ import type { StatePathOptions } from "../notifier/state";
15
+ import { resolveStoreIds } from "../seed/drivers";
16
+ import { readBootstrapVars } from "./bootstrapVars";
17
+ import { readDevSecrets } from "./file";
18
+ import { resolveDevSecretsFile } from "./location";
19
+
20
+ /**
21
+ * The local `SECRETS` D1, opened the way `wrangler dev` opens it — the same Miniflare store under the
22
+ * project root's `.wrangler/state`, so a row seeded here is a row the running Worker reads.
23
+ *
24
+ * **Three things have to be true before a secret can be seeded**, and each is a different fix, so each
25
+ * comes back named rather than as one "seeding failed": the Worker must declare the `SECRETS` binding
26
+ * (`pithy add secrets`), the project must have a dev master key (the same command mints it), and the
27
+ * database must be migrated (`pithy migrate`). None of them is an error — a project that never composed
28
+ * `secrets` is a perfectly good project — so this answers with a reason, and the caller decides whether
29
+ * to print it. `pithy add` and `pithy dev` must not fail over a capability the project does not have.
30
+ */
31
+
32
+ /**
33
+ * The D1 binding name the secrets store reads through, fixed across environments. It is `SecretsStoreEnv`'s
34
+ * `SECRETS` field and the secrets manifest's required `d1` binding — stated here because this file opens
35
+ * that store from outside a Worker, where neither of those types is in play.
36
+ */
37
+ export const SECRETS_D1_BINDING = "SECRETS";
38
+
39
+ /**
40
+ * Where a project's local dev D1 databases are persisted — the directory `wrangler dev` writes into, and
41
+ * the one thing that makes a store provably *this project's local dev store* rather than something a
42
+ * caller called `dev`.
43
+ *
44
+ * Exported because the seeder asserts against it (#159). It is a destination, not a claim: a handle over
45
+ * a remote D1 cannot name this path and mean it.
46
+ */
47
+ export function localDevStorePath(projectDir: string): string {
48
+ return join(localDevStateRoot(projectDir), "v3", "d1");
49
+ }
50
+
51
+ /**
52
+ * The one local state root a project has, and the only statement of where it is.
53
+ *
54
+ * **One store for the whole project, at its root.** Each Worker runs in its own `apps/<name>/`, where
55
+ * wrangler would otherwise create a private `.wrangler/` — so two Workers sharing a binding would not
56
+ * share the data. Every worktree gets its own, which is the point: state follows the checkout.
57
+ *
58
+ * Exported because `dev`'s orchestrator hands it to wrangler as `--persist-to`, and the scaffolded
59
+ * `vite.config.ts` names the same directory as a **relative** path from `apps/<worker>/`. Those two must
60
+ * agree, and only one of them can be checked by reading it: a wrong depth in the relative one resolves
61
+ * silently to `apps/.wrangler/state`, and the failure is a Worker reading an empty database rather than
62
+ * an error. `packages/cli/src/project/scaffoldGates.test.ts` holds the template against this function —
63
+ * see `pithy-sh/pithy#404`.
64
+ */
65
+ export function localDevStateRoot(projectDir: string): string {
66
+ return join(projectDir, ".wrangler", "state");
67
+ }
68
+
69
+ /** An open local store, or the reason there is none. `dispose` is safe to call either way. */
70
+ export type DevSecretsStoreHandle =
71
+ | {
72
+ readonly ready: true;
73
+ /** The store `seedDevSecrets` writes through — the real `SystemSecretsStore` over the local D1. */
74
+ readonly store: DevSecretsStore;
75
+ /**
76
+ * Where rows written through {@link store} actually land. The seeder refuses anything that is not
77
+ * {@link localDevStorePath} for the project it was asked about — see `assertLocalDevStore`.
78
+ *
79
+ * Required, and that is the point: a handle assembled without it does not compile, so a future
80
+ * store seam cannot quietly become a path dev secrets reach a managed environment through.
81
+ */
82
+ readonly persistPath: string;
83
+ readonly dispose: () => Promise<void>;
84
+ }
85
+ | {
86
+ readonly ready: false;
87
+ /** One sentence naming what is missing and the command that supplies it. Never a value. */
88
+ readonly reason: string;
89
+ readonly dispose: () => Promise<void>;
90
+ };
91
+
92
+ /** What {@link openDevSecretsStore} needs to find the store a Worker would read. */
93
+ export interface OpenDevSecretsStoreOptions {
94
+ /**
95
+ * The project root — owner of the `.wrangler/state` Miniflare stores every Worker shares, and of the
96
+ * one `.dev.vars` the dev master key lives in. Never a Worker directory: persistence is project-scoped
97
+ * (see the seed driver), and a per-Worker root would seed a database no Worker opens.
98
+ */
99
+ projectDir: string;
100
+ /** The Worker's directory — its `wrangler.jsonc` declares the `SECRETS` binding and its database id. */
101
+ workerDir: string;
102
+ /** The Worker's name, for the reason text. Defaults to naming no Worker. */
103
+ worker?: string;
104
+ /**
105
+ * **Unsayable on purpose (#159).** There is no environment here, and there must not be one: this opens
106
+ * the project's own Miniflare-backed store and nothing else. `never` means a caller that tries to pass
107
+ * `prod` fails to compile, which is stronger than any check it could fail at runtime.
108
+ */
109
+ env?: never;
110
+ /** Where the Pithy config directory is. Defaults to the real one; a seam so a test reads its own. */
111
+ paths?: StatePathOptions;
112
+ }
113
+
114
+ /** Open the local secrets store for one Worker, or say why there is none to open. */
115
+ export async function openDevSecretsStore(options: OpenDevSecretsStoreOptions): Promise<DevSecretsStoreHandle> {
116
+ const noop = { dispose: async () => {} };
117
+ const scope = options.worker ? ` --worker ${options.worker}` : "";
118
+
119
+ const ids = await resolveStoreIds({ workerDir: options.workerDir, env: "dev" });
120
+ const databaseId = ids.d1.get(SECRETS_D1_BINDING);
121
+ if (!databaseId) {
122
+ return { ready: false, reason: `No ${SECRETS_D1_BINDING} D1 binding. Run pithy add secrets${scope}.`, ...noop };
123
+ }
124
+
125
+ // The dev secrets file, which is what the Worker's generated `.dev.vars` is built from (#179) —
126
+ // materialised through the same function the generator uses, so the key this store opens with and the
127
+ // key the running Worker receives cannot be two different strings.
128
+ //
129
+ // **Both older homes are still read, in the order they were used.** `dev.json` is where #154 put it and
130
+ // where an upgrading project still has it; the project root's `.dev.vars` is where it lived before that
131
+ // and still does until `pithy add secrets` adopts it. Reading only the current one would make every
132
+ // project that has not upgraded unopenable — and this is the master key, so an "absent" answer is not a
133
+ // fresh start: it is every secret already encrypted under it becoming unreadable.
134
+ const stated = await statedMasterKey(options.projectDir, options.paths ?? {});
135
+ // **A file that states a key which will not read ends it here, whatever an older home holds (#325).**
136
+ // The fallbacks below are for a file that says nothing, and this file has said something. Reading past
137
+ // it opened the store under the *older* key without a word about the newer claim — and this file is
138
+ // what every Worker's `.dev.vars` is generated from, so the next seed encrypted rows under a key the
139
+ // running Worker is never handed. The only symptom is a decrypt failure, three commands later.
140
+ //
141
+ // Deliberately a refusal rather than a fallback with a warning: nothing in this function prints, and a
142
+ // handle carries one sentence. Refusing is the only answer here that a caller cannot miss.
143
+ if (stated.unreadable) return { ready: false, reason: stated.unreadable, ...noop };
144
+
145
+ const masterKey =
146
+ stated.value ||
147
+ (await readBootstrapVars(options.projectDir, options.paths ?? {}))[MASTER_KEY_BINDING] ||
148
+ parseDevVars(await readFile(join(options.projectDir, ".dev.vars"), "utf8").catch(() => ""))[MASTER_KEY_BINDING];
149
+ if (!masterKey) {
150
+ // The sentence for a project with no file to state anything, and only then the absent one. "Not
151
+ // recorded" is a claim about the file, and it may only be made when the file makes no claim (#323) —
152
+ // which includes there being no file to make one.
153
+ const reason = stated.unlocatable ?? `No ${MASTER_KEY_BINDING} recorded. Run pithy add secrets${scope}.`;
154
+ return { ready: false, reason, ...noop };
155
+ }
156
+
157
+ const persistPath = localDevStorePath(options.projectDir);
158
+ const miniflare = new Miniflare({
159
+ modules: true,
160
+ script: "export default {};",
161
+ d1Databases: { [SECRETS_D1_BINDING]: databaseId },
162
+ d1Persist: persistPath,
163
+ });
164
+ const dispose = () => miniflare.dispose();
165
+
166
+ let store: SystemSecretsStore;
167
+ try {
168
+ // Neither call touches the database — `fromEnv` resolves the master key and wraps the binding — so
169
+ // a failure here is the key, and only the key. Kept apart from the probe below so the two cannot
170
+ // answer for each other: "not migrated" over an unreadable master key sends the adopter to the
171
+ // wrong command, and they run it, and nothing changes.
172
+ store = await SystemSecretsStore.fromEnv({
173
+ SECRETS: (await miniflare.getD1Database(SECRETS_D1_BINDING)) as unknown as D1Database,
174
+ SECRETS_ENCRYPTION_KEYS: masterKey,
175
+ });
176
+ } catch {
177
+ await dispose();
178
+ return { ready: false, reason: `${MASTER_KEY_BINDING} is not a valid master key.`, ...noop };
179
+ }
180
+
181
+ try {
182
+ // The probe reads the same table the seeder writes, so "migrated" means migrated for this purpose
183
+ // and not for some adjacent one. An unmigrated database is the ordinary state right after `pithy add
184
+ // secrets`, and `no such table: pithy_secrets_system_secrets` is not an answer anybody can act on.
185
+ await store.listNames();
186
+ } catch {
187
+ await dispose();
188
+ return { ready: false, reason: "The local SECRETS database is not migrated. Run pithy migrate.", ...noop };
189
+ }
190
+ return { ready: true, store, persistPath, dispose };
191
+ }
192
+
193
+ /**
194
+ * What the dev secrets file says about the master key. At most one of the three is set; none of them
195
+ * means a readable file that simply carries no key.
196
+ *
197
+ * **The type is the fix (#323).** A `string | undefined` had one slot for two answers — "the file states
198
+ * nothing" and "the file states something that will not read" — so a bare `catch {}` collapsed them, and
199
+ * a present-but-malformed key printed `No SECRETS_ENCRYPTION_KEYS recorded. Run pithy add secrets.` That
200
+ * sentence is false about a key that is there, and the command it names then does nothing, because
201
+ * `ensureDevMasterKey` finds a key already present and returns. Two investigations died in that gap.
202
+ *
203
+ * **And the fix is one slot short, which is #325.** "The file will not answer" and "there is no file to
204
+ * ask" were still one field, and the caller has to treat them oppositely: the first overrides every older
205
+ * home, because the file is authoritative about a claim it makes; the second overrides nothing, because a
206
+ * project with no name has no file and has made no claim.
207
+ */
208
+ interface StatedMasterKey {
209
+ /** The key, materialised exactly as a Worker's binding receives it. Absent when the file has none. */
210
+ readonly value?: string;
211
+ /**
212
+ * Why the file — which is there — could not answer: the thrown error's own sentence.
213
+ *
214
+ * **Authoritative.** The file states a master key and it will not read, so no older home may stand in
215
+ * for it: opening under one encrypts rows under a key this file does not name.
216
+ */
217
+ readonly unreadable?: string;
218
+ /**
219
+ * Why there is no file to ask at all — no project, or a project with no `name`, since the path is
220
+ * resolved from the name.
221
+ *
222
+ * **Not authoritative.** Nothing was stated, so the older homes are still read, and this is only the
223
+ * sentence the caller falls back to when none of them holds a key either.
224
+ */
225
+ readonly unlocatable?: string;
226
+ }
227
+
228
+ /**
229
+ * The master key as the dev secrets file states it, or the sentence saying why it could not be read.
230
+ *
231
+ * Through {@link devVarsForRegistry} and {@link masterKeyRegistryEntry} rather than by reaching into the
232
+ * envelope here: the entry is what says the value is an `EncryptionConfig` and that its binding carries
233
+ * the value rather than the envelope, and a second reading of that in this file is how the seeder and the
234
+ * store come to disagree about what the key is.
235
+ *
236
+ * **Nothing new is written here.** Every reader below already throws a `PithyError` naming the secret,
237
+ * the file, and the fix — `requireProjectName`, `readDevSecretsSource`, `loadDevSecrets`,
238
+ * `storedSecretValue`. The defect was never a missing message; it was a `catch` that threw four of them
239
+ * away. So this carries each one out verbatim, and a fifth reader gets its sentence for free.
240
+ */
241
+ async function statedMasterKey(projectDir: string, paths: StatePathOptions): Promise<StatedMasterKey> {
242
+ // Resolved separately from the read, because a path that does not resolve is a different claim: the
243
+ // project has no name, so it has no secrets file, so nothing was ever recorded in one.
244
+ let path: string;
245
+ try {
246
+ path = await resolveDevSecretsFile(projectDir, paths);
247
+ } catch (error) {
248
+ // `unlocatable`, never `unreadable` (#325). There is no file here, so nothing has been stated, and
249
+ // an older home is still the honest place to look for the key.
250
+ return { unlocatable: `No ${MASTER_KEY_BINDING} recorded: ${sentenceOf(error)}` };
251
+ }
252
+
253
+ try {
254
+ const file = await readDevSecrets(path);
255
+ const value = devVarsForRegistry(file, { [MASTER_KEY_BINDING]: masterKeyRegistryEntry }, path)[MASTER_KEY_BINDING];
256
+ // An absent key in a readable file is the one state `No … recorded` is true of, so it is the one
257
+ // state that says nothing here and lets the caller's own sentence stand.
258
+ return value === undefined ? {} : { value };
259
+ } catch (error) {
260
+ return { unreadable: sentenceOf(error) };
261
+ }
262
+ }
@@ -0,0 +1,204 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join } from "node:path";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { isSecretsCapability } from "@pithy-sh/secrets/src/capability";
7
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
8
+ import { aggregateSecretRegistries } from "@pithy-sh/secrets/src/sharedSecretsStore";
9
+ import { loadWorkerConfig, type WorkerConfig } from "../project/config";
10
+ import { resolveWorkers } from "../project/workerScope";
11
+ import { discoverWorkers } from "../project/workers";
12
+ import { ownProperties } from "./records";
13
+
14
+ /**
15
+ * Which Workers a dev-secrets run acts on, and the registry each one resolves secrets through.
16
+ *
17
+ * **Its own module, because both ends of the run need it and one of them is upstream of the other.**
18
+ * `pithy seed` needs the targets to know where a `d1` value goes; the generator needs them to know which
19
+ * `cf-secrets-store` secrets to materialise into each Worker's `.dev.vars` (#179). With this in the
20
+ * seeder, the generator importing it closed a cycle — generator → seeder → writer → generator — and the
21
+ * answer to "which Workers, with which registry" is neither of their business to own privately.
22
+ */
23
+
24
+ /** One Worker's contribution: its name, its directory, and the registry that decides its destinations. */
25
+ export interface DevSecretsTarget {
26
+ /** The Worker's name — what a skipped reason names, so the adopter knows which one to fix. */
27
+ name: string;
28
+ /** The Worker's directory; its `wrangler.jsonc` declares the `SECRETS` binding. */
29
+ dir: string;
30
+ /** That Worker's secret registry. */
31
+ registry: SecretRegistry;
32
+ }
33
+
34
+ /** A Worker that has a `pithy.config.ts` and could not be asked what it declares. Never a value. */
35
+ export interface UnresolvableWorker {
36
+ /** The Worker's name — what the report names, so the adopter knows which config to fix. */
37
+ name: string;
38
+ /** The Worker's directory. Its `.dev.vars` is the file that went without. */
39
+ dir: string;
40
+ /** Why the config did not import, already actionable. Reaches a terminal, so never `detail`. */
41
+ reason: string;
42
+ }
43
+
44
+ /**
45
+ * Which Workers declare secrets, **and which ones could not be asked** — the two answers that must not
46
+ * arrive as one (#199).
47
+ *
48
+ * **An array cannot say "and this one is broken", which is the whole defect.** `resolveWorkers` throws
49
+ * for a config that is present and will not import, deliberately: swallowing it prints "No workers here"
50
+ * for a project that plainly has workers. A lossy wrapper here caught that throw into `[]` — and
51
+ * `[]` is also what a project that never composed `secrets` returns. So every consumer read a config
52
+ * that would not load as a Worker that declares nothing. Since #179 that is not a cosmetic conflation:
53
+ * a `cf-secrets-store` secret is materialised only from the registry, so an empty registry means the
54
+ * generated `.dev.vars` is written down to its header and the Worker starts with **no bindings at all**.
55
+ * `pithy dev` did exactly that and printed `Starting replay-board.` and nothing else.
56
+ *
57
+ * Behavior is unchanged and correct — a registry nobody can read has no honest answer, and the old
58
+ * answer came from the `dev.json` copy #179 exists to delete. What changes is that the failure is now a
59
+ * field rather than an absence, so a caller has to drop it on purpose instead of by taking the only
60
+ * thing there was to take.
61
+ *
62
+ * **Resolved one Worker at a time, so a failure costs exactly one.** `resolveWorkers`' fan-out throws on
63
+ * the first bad config, which took every healthy sibling's registry with it: a project with two Workers
64
+ * lost both because one of them had a typo. Discovery happens once and the single-Worker path — the same
65
+ * `pick`-then-load `resolveWorkers` already owns, seams included — is driven per target.
66
+ *
67
+ * **A missing `pithy.config.ts` is skipped, not reported.** A Vite frontend joining the dev set through
68
+ * `pithy.worker.jsonc` alone has no config and declares nothing; that is the ordinary state, not a
69
+ * failure, and reporting it would put a permanent warning in front of every project with a frontend.
70
+ * Exactly `core/not_found`, which is the code {@link loadWorkerConfig} raises for the absent file and
71
+ * nothing else — the same rule, and the same test, `resolveWorkers`' own loop applies.
72
+ *
73
+ * **The registry is the aggregate, not the secrets capability's own slice.** `aggregateSecretRegistries`
74
+ * is the exact call the Worker makes at composition: every capability contributes the secrets it owns,
75
+ * and `auth-session-secret` is auth's declaration, not something an adopter re-types into
76
+ * `secrets({ registry })`. Reading only the secrets capability's slice seeded nothing in a real project
77
+ * and threw outright in a scaffolded one, where `pithy add secrets` writes `secrets({ rotationIntervalDays })`
78
+ * and leaves `registry` for the adopter — so the slice is `undefined` on a config the CLI itself wrote.
79
+ */
80
+ export async function resolveDevSecretsTargets(
81
+ projectDir: string,
82
+ options: DevSecretsTargetsOptions = {},
83
+ ): Promise<DevSecretsResolution> {
84
+ // Never throws. A directory that is not a project has no workers, no secrets, and nothing to report —
85
+ // `pithy add` runs this wherever the adopter is standing, and refusing there would fail a command that
86
+ // has nothing to do. That is the one absence this module is still allowed to answer with silence.
87
+ const discovered = await discoverWorkers(projectDir).catch(() => []);
88
+ const scoped =
89
+ options.worker === undefined
90
+ ? discovered
91
+ : discovered.filter((target) => target.name === options.worker || target.dir.endsWith(`/${options.worker}`));
92
+
93
+ const targets: DevSecretsTarget[] = [];
94
+ const unresolvable: UnresolvableWorker[] = [];
95
+ for (const target of scoped) {
96
+ const [resolved] = await resolveWorkers({
97
+ projectDir,
98
+ worker: target.name,
99
+ // Discovery is already done; this hands `resolveWorkers` the one target so its single-Worker path
100
+ // loads exactly this config and its failure belongs to exactly this Worker.
101
+ discoverWorkers: async () => [target],
102
+ ...(options.reload ? { loadConfig: reloadWorkerConfig } : {}),
103
+ }).catch((error: unknown) => {
104
+ if (error instanceof PithyError && error.payload.code === "core/not_found") return [];
105
+ unresolvable.push({ name: target.name, dir: target.dir, reason: messageOf(error) });
106
+ return [];
107
+ });
108
+ if (!resolved) continue;
109
+ if (!resolved.capabilities.some(isSecretsCapability)) continue;
110
+ targets.push({
111
+ name: resolved.name,
112
+ dir: resolved.dir,
113
+ // Prototype-free, so `registry[name]` here and in every consumer — `pithy doctor` included — is an
114
+ // own-property lookup for a secret a capability chose to call `constructor`. See {@link ownProperties}.
115
+ registry: ownProperties(aggregateSecretRegistries(resolved.capabilities)),
116
+ });
117
+ }
118
+ return { targets, unresolvable };
119
+ }
120
+
121
+ /** Both halves of one resolution: what declared secrets, and what could not be asked. */
122
+ export interface DevSecretsResolution {
123
+ /** Every Worker that composes `secrets`, with the registry it resolves them through. */
124
+ targets: DevSecretsTarget[];
125
+ /** Every Worker with a `pithy.config.ts` that would not import, and why. Empty on an ordinary run. */
126
+ unresolvable: UnresolvableWorker[];
127
+ }
128
+
129
+ /**
130
+ * One actionable sentence from a thrown failure.
131
+ *
132
+ * `message` and `action` together, never `detail`: `detail` is throw-site context — for
133
+ * {@link loadWorkerConfig} it is the raw module-resolution error — and these lines reach a terminal and
134
+ * `logs/dev.log`.
135
+ */
136
+ function messageOf(error: unknown): string {
137
+ if (error instanceof PithyError) return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
138
+ return error instanceof Error ? error.message : String(error);
139
+ }
140
+
141
+ /** How {@link resolveDevSecretsTargets} narrows and how it loads. Both default to the whole project, cached. */
142
+ export interface DevSecretsTargetsOptions {
143
+ /** Narrow to one Worker, by the name `pithy worker list` shows or its `apps/<dir>` basename. */
144
+ worker?: string;
145
+ /**
146
+ * Re-import each `pithy.config.ts` rather than taking the module this process already holds.
147
+ *
148
+ * For **`pithy add`, and only for it.** That command rewrites the Worker's config and then seeds, in
149
+ * one process that imported the config before the write — so the aggregate registry it seeds against
150
+ * is the composition from *before* the add, and the secret the same run has just minted never
151
+ * reaches the store. The next unrelated command picked it up, which made it look like a store
152
+ * problem rather than a stale module.
153
+ *
154
+ * Off by default because it is not free: every reload adds a module instance to the ESM registry for
155
+ * the life of the process, and re-runs the config's top-level code. `pithy dev` and `pithy seed` load
156
+ * the config once and never rewrite it, so they have nothing stale to correct.
157
+ */
158
+ reload?: boolean;
159
+ /** **Unsayable on purpose (#159).** Targets are the project's own Workers; there is no environment. */
160
+ env?: never;
161
+ }
162
+
163
+ /** Distinguishes one reload from the next, so two in a process both get a module and not the first one twice. */
164
+ let reloadCount = 0;
165
+
166
+ /**
167
+ * Every target's registry, merged by secret name — the same join `pithy secrets` does.
168
+ *
169
+ * One function rather than one per command, because a secret's registry entry is what says how its value
170
+ * is shaped, and two merges is two answers to that (#323). Later targets win on a repeated name, which is
171
+ * the state `pithy doctor` reports rather than one this has an opinion about.
172
+ */
173
+ export function mergedSecretRegistry(targets: readonly DevSecretsTarget[]): SecretRegistry {
174
+ const merged: SecretRegistry = {};
175
+ for (const target of targets) for (const [name, entry] of Object.entries(target.registry)) merged[name] = entry;
176
+ return merged;
177
+ }
178
+
179
+ /**
180
+ * One Worker's `pithy.config.ts`, imported past the ESM module cache with a query the resolver ignores
181
+ * and the cache key does not.
182
+ *
183
+ * **The specifier is the absolute path, not `pathToFileURL`, and that is load-bearing under Bun.** Bun
184
+ * honors the query as part of the cache key for a path specifier and ignores it for a `file://` URL:
185
+ * `import("/abs/pithy.config.ts?v=2")` re-evaluates, `import("file:///abs/pithy.config.ts?v=2")` hands
186
+ * back the first instance. The URL form is what {@link loadWorkerConfig} uses and is right there — it
187
+ * *wants* the cache. Written the same way here, `pithy add auth` seeded nothing and said nothing about
188
+ * it, because the fallback below silently returned the stale module. Vitest's module runner busts on
189
+ * either form, so no unit test can tell the two apart; the CLI itself was what caught it.
190
+ *
191
+ * Every failure falls back to {@link loadWorkerConfig}, which owns the two actionable errors — a config
192
+ * that is absent, and one that will not import — so nothing here has to restate them, and a config that
193
+ * loads clean but exports the wrong shape is answered by the same message it always was.
194
+ */
195
+ async function reloadWorkerConfig(workerDir: string): Promise<WorkerConfig> {
196
+ const path = join(workerDir, "pithy.config.ts");
197
+ reloadCount += 1;
198
+ const module = await import(`${path}?pithy-reload=${reloadCount}`).catch(() => null);
199
+ const config: unknown = module?.default;
200
+ if (config === null || typeof config !== "object" || !Array.isArray((config as WorkerConfig).capabilities)) {
201
+ return loadWorkerConfig(workerDir);
202
+ }
203
+ return config as WorkerConfig;
204
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CommandDef } from "citty";
5
+
6
+ /**
7
+ * **A command that names no action is asking what it can do. Answering it is a success.**
8
+ *
9
+ * That is the rule, stated once, for every level of the tree. `pithy` is somebody's first day; `pithy
10
+ * secrets` is the same question one level down. Both are answered by a command list, and a command list
11
+ * is the right answer, so neither is an error.
12
+ *
13
+ * citty disagrees, structurally. `runCommand` throws `E_NO_COMMAND` whenever a command declares
14
+ * `subCommands`, receives no subcommand name, has no `default`, and has no `run` of its own — and
15
+ * `runMain` catches every `CLIError` the same way: print the usage, echo the message, `process.exit(1)`.
16
+ * So the bare invocation printed a complete command list and then said `No command specified.` under it,
17
+ * arguing with a user it had just served, and exited non-zero — which fails `pithy && next`, fails a CI
18
+ * step, and under `bun run` adds `error: script "pithy" exited with code 1` as the loudest and least
19
+ * informative line on screen (#319).
20
+ *
21
+ * **One rule, one place.** Fifteen commands take that path — the root and fourteen groups — and giving
22
+ * each of them a `run` would be fifteen producers of one rule, plus a subtlety that guarantees drift:
23
+ * citty runs a parent's `run` *after* dispatching to a subcommand, so each would need to know whether it
24
+ * had been dispatched through. This module answers the question before citty is asked, so a group added
25
+ * next year inherits the rule with nothing to remember.
26
+ *
27
+ * What is **not** covered, deliberately: a name that is not a command. `pithy nonsense` is a mistake, not
28
+ * a question, and citty's own `E_UNKNOWN_COMMAND` already names it, shows the help, and exits non-zero.
29
+ * The walk stops at the first token it cannot resolve and hands the whole invocation back untouched.
30
+ *
31
+ * **Reaching that path requires the tree to answer only to the names it declares**, which an object
32
+ * literal does not — see {@link ownNamesOnly}.
33
+ */
34
+
35
+ /** A command and the parent it was reached through — what citty's `showUsage` takes. */
36
+ export interface UsageTarget {
37
+ /** The command whose usage to render. */
38
+ cmd: CommandDef;
39
+ /** Its parent, so the rendered line reads `pithy secrets` rather than `secrets`. Absent at the root. */
40
+ parent?: CommandDef;
41
+ }
42
+
43
+ /** citty's lazy subcommand value: a definition, a promise of one, or a thunk returning either. */
44
+ type SubCommand = CommandDef | Promise<CommandDef> | (() => CommandDef | Promise<CommandDef>);
45
+
46
+ /** Resolve one of citty's three subcommand spellings to a definition. */
47
+ async function resolve(value: SubCommand): Promise<CommandDef> {
48
+ return typeof value === "function" ? await value() : await value;
49
+ }
50
+
51
+ /**
52
+ * The same command tree, answering only to the names it declares.
53
+ *
54
+ * A `subCommands` is an object literal, so `Object.prototype` is a member of every lookup at every level
55
+ * — and citty resolves a subcommand with `name in subCommands`, then calls the value when it is a
56
+ * function. `pithy valueOf` therefore called `Object.prototype.valueOf` with no receiver and died on a
57
+ * raw `TypeError` under a crash banner, and `pithy constructor` called `Object`, took the `{}` it
58
+ * returned for a command definition, ran nothing at all, and exited **0**. Neither name is a command.
59
+ * Both belong on the path `pithy nonsense` takes.
60
+ *
61
+ * Copying each record onto a null prototype is the whole fix: an inherited name resolves to nothing, and
62
+ * citty's `E_UNKNOWN_COMMAND` names it and exits non-zero exactly as it does for a typo. Done here, once,
63
+ * rather than at the twenty-six `defineCommand` calls — the same reason the usage rule is: a group
64
+ * added next year inherits it with nothing to remember. **Laziness survives**: a thunk is wrapped, never
65
+ * called, so the imports this tree defers stay deferred until a name is actually walked into.
66
+ */
67
+ export function ownNamesOnly(cmd: CommandDef): CommandDef {
68
+ const declared = cmd.subCommands;
69
+ if (declared === undefined) return cmd;
70
+ return {
71
+ ...cmd,
72
+ subCommands: async () => {
73
+ const subCommands = (typeof declared === "function" ? await declared() : await declared) as Record<
74
+ string,
75
+ SubCommand
76
+ >;
77
+ const own: Record<string, SubCommand> = Object.create(null);
78
+ for (const [name, value] of Object.entries(subCommands))
79
+ own[name] = async () => ownNamesOnly(await resolve(value));
80
+ return own;
81
+ },
82
+ } as CommandDef;
83
+ }
84
+
85
+ /**
86
+ * Find a subcommand by the name the user typed, matching citty's own lookup: the key first, then any
87
+ * command whose `meta.alias` claims the name.
88
+ *
89
+ * **The key is an own name or it is not a name.** `subCommands[name]` alone answers `valueOf` and
90
+ * `constructor` with `Object.prototype`'s members, which is the hole {@link ownNamesOnly} closes for
91
+ * citty; this walk states the same rule for itself, because it is exported and takes any `CommandDef` —
92
+ * including one nobody hardened.
93
+ *
94
+ * Resolving every sibling to read its alias costs the lazy imports this tree exists to avoid, so the key
95
+ * is tried first and the alias scan only runs when it misses — which is the miss path already, and the
96
+ * path that ends in "unknown command" either way.
97
+ */
98
+ async function findSubCommand(subCommands: Record<string, SubCommand>, name: string): Promise<CommandDef | undefined> {
99
+ const direct = Object.hasOwn(subCommands, name) ? subCommands[name] : undefined;
100
+ if (direct !== undefined) return resolve(direct);
101
+ for (const value of Object.values(subCommands)) {
102
+ const candidate = await resolve(value);
103
+ const meta = typeof candidate.meta === "function" ? await candidate.meta() : await candidate.meta;
104
+ const alias = meta?.alias;
105
+ if (alias === name || (Array.isArray(alias) && alias.includes(name))) return candidate;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Whether a command can act on its own — through its own `run`, or by defaulting to a subcommand. */
111
+ function actsOnItsOwn(cmd: CommandDef): boolean {
112
+ return typeof cmd.run === "function" || cmd.default !== undefined;
113
+ }
114
+
115
+ /**
116
+ * The command whose usage answers this invocation, or `null` when the invocation names an action and
117
+ * belongs to citty.
118
+ *
119
+ * Flags are skipped rather than resolved: only commands that dispatch are walked into, and a dispatching
120
+ * command in this tree declares no `args` of its own, so a leading `-` is a builtin (`--help`,
121
+ * `--version`) or a flag for a group that has none. The walk stops the moment a token resolves to
122
+ * nothing, so an unknown command reaches citty exactly as before.
123
+ */
124
+ export async function usageTarget(root: CommandDef, argv: readonly string[]): Promise<UsageTarget | null> {
125
+ let cmd = root;
126
+ let parent: CommandDef | undefined;
127
+
128
+ for (const token of argv) {
129
+ if (token.startsWith("-")) continue;
130
+ const subCommands = (typeof cmd.subCommands === "function" ? await cmd.subCommands() : await cmd.subCommands) as
131
+ | Record<string, SubCommand>
132
+ | undefined;
133
+ if (!subCommands) return null;
134
+ const next = await findSubCommand(subCommands, token);
135
+ // An unknown name is a mistake, not a question. Hand it back — citty names it and exits non-zero.
136
+ if (!next) return null;
137
+ parent = cmd;
138
+ cmd = next;
139
+ }
140
+
141
+ const subCommands = (typeof cmd.subCommands === "function" ? await cmd.subCommands() : await cmd.subCommands) as
142
+ | Record<string, SubCommand>
143
+ | undefined;
144
+ if (!subCommands || Object.keys(subCommands).length === 0) return null;
145
+ if (actsOnItsOwn(cmd)) return null;
146
+ return parent === undefined ? { cmd } : { cmd, parent };
147
+ }