@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,288 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { BindingSpec } from "@pithy-sh/core/src/capability/bindings";
7
+ import {
8
+ type CapabilityManifest,
9
+ renderCapabilityImport,
10
+ renderCapabilityRegistration,
11
+ renderConfigOptionComment,
12
+ renderConfigOptionLine,
13
+ } from "@pithy-sh/core/src/capability/manifest";
14
+ import { ConflictError, InternalError } from "@pithy-sh/core/src/error/pithyError";
15
+ import {
16
+ appendBinding,
17
+ appendDurableObjectMigrations,
18
+ type BindingScope,
19
+ envStanzas,
20
+ type ProposedName,
21
+ type WranglerStanza,
22
+ } from "../project/bindingEntries";
23
+ import { readOptionalFile } from "../project/readOptionalFile";
24
+ import { readWranglerConfig, workerEntryPath, writeWranglerConfig } from "../project/wrangler";
25
+ import { optionValue } from "./configConstants";
26
+ import { capabilityImportSpecifier, findNamedImport, importOrigin } from "./configImports";
27
+ import { ejectImportPath } from "./eject";
28
+ import { durableObjectExports, withDurableObjectExports } from "./entryExports";
29
+ import { requiredOptionRefusal } from "./requiredOptions";
30
+
31
+ /** A config option's value: the JSON scalars a manifest default can be. */
32
+ export type ConfigValue = string | number | boolean;
33
+
34
+ export interface AddCapabilityOptions {
35
+ /**
36
+ * The **Worker's** directory (`apps/<name>`) — where that Worker's `pithy.config.ts` and
37
+ * `wrangler.jsonc` live. Capabilities are per-Worker: the composed route tree, the bindings, and the
38
+ * Durable Object class migrations all attach to one script, so wiring never touches the project root.
39
+ */
40
+ workerDir: string;
41
+ /** The capability's validated manifest (pithy.manifest.json shape). */
42
+ manifest: CapabilityManifest;
43
+ /** Per-option overrides; an unset option renders its manifest default. */
44
+ configValues?: Record<string, ConfigValue>;
45
+ /**
46
+ * The project name, resolved by the caller from the root `pithy.config.ts` (`requireProjectName`) and
47
+ * passed as a plain string. It is the first segment of every name proposed here.
48
+ *
49
+ * **A string, never a loader.** Resolving it in here would mean importing the root config live, and a
50
+ * live import only works under the project root — the wiring tests scaffold into the OS tmpdir, where
51
+ * it fails with an error about the config rather than about the test. The caller already has the
52
+ * config; it hands over the answer.
53
+ *
54
+ * Omitted when no project name could be resolved: nothing is proposed and the entries carry only their
55
+ * binding, which is exactly what `pithy add` wrote before. A guessed prefix would be worse than none —
56
+ * every command that later recomputes the name would compute a different one.
57
+ */
58
+ project?: string;
59
+ }
60
+
61
+ /** What {@link addCapability} wired that the config file itself cannot carry. */
62
+ export interface AddCapabilityResult {
63
+ /**
64
+ * The KV namespace titles to give the namespaces this capability needs.
65
+ *
66
+ * **KV is reported rather than written, because wrangler has nowhere to write it.** A `kv_namespaces`
67
+ * entry takes `binding`, `id`, `preview_id`, and `remote` — there is no title field, so the name lives
68
+ * only in the account. D1 is different (`database_name` is a real key) and is written into the file.
69
+ * R2 is deliberately absent from both: `pithy storage provision` and `pithy media provision` write
70
+ * `bucket_name` themselves, and a second writer would collide with them.
71
+ *
72
+ * Empty when no project name was resolved, and empty on a re-run — a binding already present is left
73
+ * exactly as the adopter has it.
74
+ */
75
+ kvNamespaces: ProposedName[];
76
+ }
77
+
78
+ /** The managed-region marker each Worker's `pithy.config.ts` plants inside `capabilities: [...]`. */
79
+ const MARKER = "// pithy:capabilities";
80
+
81
+ /**
82
+ * Wire a capability into **one Worker** — the pure logic behind `pithy add`. Inserts the import and
83
+ * registration into that Worker's `pithy.config.ts` managed region and appends the manifest's required
84
+ * bindings to every environment of that Worker's `wrangler.jsonc`, comment-preserving. Idempotent: a
85
+ * second run changes nothing. A sibling Worker is never touched.
86
+ */
87
+ export async function addCapability(options: AddCapabilityOptions): Promise<AddCapabilityResult> {
88
+ await updateConfig(options);
89
+ const kvNamespaces = await updateWrangler(options);
90
+ await updateEntry(options);
91
+ return { kvNamespaces };
92
+ }
93
+
94
+ /**
95
+ * The bindings `pithy add` actually wires into this Worker: the manifest's, minus the optional ones.
96
+ *
97
+ * An `optional` binding is one the capability needs only under a particular config. The manifest is one
98
+ * static file and cannot vary with config, so it declares the union and marks such a binding optional;
99
+ * `pithy add` runs *before* any config exists and has nothing to resolve the flag against, so it writes
100
+ * none of them. `createBackend` reads the same flag to decide whether a missing binding is fatal at
101
+ * assembly, and `pithy upgrade` — which does have the composed set — writes the ones that Worker derives.
102
+ *
103
+ * **One list, read three times**: the `wrangler.jsonc` stanzas, the Durable Object class migration tags,
104
+ * and the entry's exports. They were three separate filters over the same manifest and they did not
105
+ * agree — the tags took every declared binding — so an optional Durable Object would have been registered
106
+ * against the script by a `new_sqlite_classes` tag while nothing bound it and nothing exported it. A tag
107
+ * is applied once and never revisited, so that is not a mistake a later run repairs.
108
+ */
109
+ function wiredBindings(manifest: CapabilityManifest): BindingSpec[] {
110
+ return manifest.requiredBindings.filter((binding) => !binding.optional);
111
+ }
112
+
113
+ /**
114
+ * Write the capability's Durable Object exports into the Worker's entry — the other half of a
115
+ * `durable_objects.bindings` entry, and the half `wrangler deploy` refuses the Worker without (#428).
116
+ *
117
+ * Silent, like the class migration tag beside it: there is nothing for the adopter to do about it, and a
118
+ * line of output for every file a command touches is not this CLI's voice. The entry is whatever `main`
119
+ * names, so a Worker that keeps its module somewhere else still gets the export where wrangler looks.
120
+ *
121
+ * Over {@link wiredBindings}, so a class is exported exactly when it is bound.
122
+ */
123
+ async function updateEntry({ workerDir, manifest }: AddCapabilityOptions): Promise<void> {
124
+ const exports = durableObjectExports(wiredBindings(manifest));
125
+ if (exports.length === 0) return;
126
+
127
+ const path = await workerEntryPath(workerDir);
128
+ const source = path === null ? null : await readOptionalFile(path);
129
+ if (path === null || source === null) {
130
+ const classes = exports.map((entry) => entry.className).join(", ");
131
+ const wrangler = join(workerDir, "wrangler.jsonc");
132
+ // Two faults, two remedies. A config with no `main` has named no file, so there is nothing to
133
+ // restore and the fix is in `wrangler.jsonc`; a `main` whose file is gone is the opposite. The
134
+ // message already said which of the two it was, and one shared action line contradicted half of it.
135
+ throw new InternalError({
136
+ message:
137
+ path === null
138
+ ? `${wrangler} names no main, so this Worker has no entry to export ${classes} from.`
139
+ : `${path} is missing — this Worker's wrangler.jsonc names it as main.`,
140
+ action:
141
+ path === null
142
+ ? `Give ${wrangler} a main naming this Worker's entry, then run pithy add ${manifest.name} again.`
143
+ : `Restore that file, then run pithy add ${manifest.name} again.`,
144
+ detail: `${manifest.name} binds ${classes}, which wrangler resolves against the Worker's entry.`,
145
+ });
146
+ }
147
+ const written = withDurableObjectExports(source, exports);
148
+ if (written !== source) await writeFile(path, written);
149
+ }
150
+
151
+ /** Escape a capability name for use inside a `RegExp` (names are simple, but be safe). */
152
+ function escapeRegExp(text: string): string {
153
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
154
+ }
155
+
156
+ /**
157
+ * Render a capability's registration. With no config options it's a one-liner
158
+ * (`auth(),`); with options it's a block — one commented `key: default` per
159
+ * option — so `pithy.config.ts` documents itself (docs/CLI.md §Config). The mount
160
+ * path and every other knob live here, in the user's surface; the handler stays
161
+ * in the package.
162
+ *
163
+ * Every line of it comes from core's renderers now, the call included. This function used to interpolate
164
+ * `manifest.name` into the call itself, which is how a manifest declaring `audit }) ; evil(` closed the
165
+ * capabilities array and opened a call of its own (#183) — the same defect as the option key #174 closed,
166
+ * one line up.
167
+ */
168
+ function renderRegistration(
169
+ manifest: CapabilityManifest,
170
+ configValues: Record<string, ConfigValue>,
171
+ indent: string,
172
+ source: string,
173
+ ): string {
174
+ const inner = `${indent} `;
175
+ const optionLines: string[] = [];
176
+ for (const option of manifest.configOptions) {
177
+ // The scaffold's `PUBLIC_ORIGIN` where the option names it and this config declares it, the
178
+ // manifest's literal otherwise, and the adopter's own value over both. See `configConstants.ts`.
179
+ const value = optionValue(option, source, configValues[option.key]);
180
+ // A required option with no value reaching here is a bug one step upstream — `runAdd` refuses before
181
+ // it calls this — so it fails loudly rather than rendering the word `undefined` into somebody's
182
+ // config. `addCapability` is called directly too, and a writer that can be reached from more than
183
+ // one place states its own preconditions.
184
+ if (value === undefined) throw requiredOptionRefusal({ capability: manifest.name, missing: [option] });
185
+ // The same two lines `pithy upgrade` writes, from the same two functions. Two renderers of one line
186
+ // is how `add` and `upgrade` came to disagree about a nested default in the first place (#171), and
187
+ // the comment was still built here and there separately until the manifest text going into it got a
188
+ // rule of its own (#174).
189
+ optionLines.push(renderConfigOptionComment(option.describe, inner));
190
+ optionLines.push(renderConfigOptionLine(option.key, value, inner));
191
+ }
192
+ return renderCapabilityRegistration({ name: manifest.name, indent, optionLines });
193
+ }
194
+
195
+ async function updateConfig({ workerDir, manifest, configValues }: AddCapabilityOptions): Promise<void> {
196
+ const path = join(workerDir, "pithy.config.ts");
197
+ let source = await readFile(path, "utf8");
198
+
199
+ const markerLine = source.split("\n").find((line) => line.trimStart().startsWith(MARKER));
200
+ if (markerLine === undefined) {
201
+ throw new InternalError({
202
+ message: `${path} has no "${MARKER}" marker.`,
203
+ action: "Restore the managed-region marker inside capabilities: []. Run pithy add again.",
204
+ });
205
+ }
206
+
207
+ const lines = source.split("\n");
208
+ // Idempotency on the import is keyed on the *binding*, then checked against where it comes from.
209
+ // Keyed on the whole line, an adopter who corrected a specifier by hand — which `pithy add secrets`
210
+ // required, for as long as `@pithy-sh/secrets` shipped no `src/index` — got the original line back
211
+ // on the next run, and two bindings of one name is a redeclaration the config never loads past.
212
+ // Keyed on the binding alone, an adopter's own `auth` suppressed our import while `auth()` still
213
+ // went into the managed region, so the config composed their middleware and said nothing. One is
214
+ // loud and wrong, the other is silent and wrong. So: the name identifies the import, the specifier
215
+ // decides what to do about it, and a name bound to something else is refused before anything is
216
+ // written.
217
+ const existing = findNamedImport(source, manifest.name);
218
+ const origin = existing && importOrigin(existing.specifier, manifest.package, ejectImportPath(manifest.name));
219
+ if (existing === undefined) {
220
+ source = `${renderCapabilityImport(manifest.name, capabilityImportSpecifier(manifest.package))}\n${source}`;
221
+ } else if (origin === "foreign") {
222
+ throw new ConflictError({
223
+ message: `${path} already imports ${manifest.name} from "${existing.specifier}".`,
224
+ action: `Rename that import, then run pithy add ${manifest.name} again.`,
225
+ detail: `Wiring ${manifest.name}() would have composed ${existing.specifier} as the capability.`,
226
+ });
227
+ } else if (origin === "unresolvable") {
228
+ // Ours, and dead: the package exports `./src/*` and no `.`, so this line throws the moment anything
229
+ // loads the config. Accepted as wiring, `add` wrote the registration against an import that could
230
+ // never bind and exited 0. Refused rather than rewritten — the specifier is the adopter's line to
231
+ // correct, and a command that silently repoints imports is one nobody can predict.
232
+ throw new ConflictError({
233
+ message: `${path} imports ${manifest.name} from "${existing.specifier}", which resolves to nothing.`,
234
+ action: `Point that import at "${capabilityImportSpecifier(manifest.package)}", then run pithy add ${manifest.name} again.`,
235
+ detail: `${manifest.package} exports ./src/* only, so the bare specifier has no entry point.`,
236
+ });
237
+ }
238
+
239
+ // Idempotency anchors on the registration *call*, not an exact line: a block
240
+ // form spans several lines, and `auth(` must not match an existing `myauth(`.
241
+ const registered = new RegExp(`^${escapeRegExp(manifest.name)}\\(`);
242
+ if (!lines.some((line) => registered.test(line.trim()))) {
243
+ const indent = markerLine.slice(0, markerLine.length - markerLine.trimStart().length);
244
+ const registration = renderRegistration(manifest, configValues ?? {}, indent, source);
245
+ // A replacement function keeps `$` in the registration literal.
246
+ source = source.replace(markerLine, () => `${registration}\n${markerLine}`);
247
+ }
248
+
249
+ await writeFile(path, source);
250
+ }
251
+
252
+ /**
253
+ * Append each of a capability's {@link wiredBindings} to one environment's stanza, and report the KV
254
+ * namespace titles the adopter has to create by hand.
255
+ *
256
+ * The entries themselves are `project/bindingEntries.ts`'s — one writer, shared with `pithy upgrade`'s
257
+ * reconcile, so the two commands cannot produce different configs from the same manifest.
258
+ */
259
+ function appendBindings(stanza: WranglerStanza, bindings: readonly BindingSpec[], scope: BindingScope): ProposedName[] {
260
+ const proposed: ProposedName[] = [];
261
+ for (const binding of bindings) {
262
+ const write = appendBinding(stanza, binding, scope);
263
+ if (write.outcome === "written" && write.proposed) proposed.push(write.proposed);
264
+ }
265
+ return proposed;
266
+ }
267
+
268
+ async function updateWrangler({ workerDir, manifest, project }: AddCapabilityOptions): Promise<ProposedName[]> {
269
+ const config = (await readWranglerConfig(workerDir)) as WranglerStanza;
270
+
271
+ const bindings = wiredBindings(manifest);
272
+ const kvNamespaces: ProposedName[] = [];
273
+ for (const { env, stanza } of envStanzas(config)) {
274
+ kvNamespaces.push(
275
+ ...appendBindings(stanza, bindings, {
276
+ ...(project === undefined ? {} : { project }),
277
+ env,
278
+ capability: manifest.name,
279
+ }),
280
+ );
281
+ }
282
+ // DO class migrations are top-level only — they register the class against the script, not per-env. The
283
+ // same list the stanzas got: a tag registers a class the Worker binds, or it registers nothing.
284
+ appendDurableObjectMigrations(config, bindings);
285
+
286
+ await writeWranglerConfig(workerDir, config);
287
+ return kvNamespaces;
288
+ }
@@ -0,0 +1,275 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join } from "node:path";
5
+ import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
6
+ import type { BindingSpec } from "@pithy-sh/core/src/capability/bindings";
7
+ import { isProvisionedBinding } from "@pithy-sh/core/src/capability/bindings";
8
+ import type { DevSecret } from "@pithy-sh/core/src/capability/devSecret";
9
+ import type { CapabilityManifest } from "@pithy-sh/core/src/capability/manifest";
10
+ import { masterKeyRegistryEntry } from "@pithy-sh/secrets/src/capability";
11
+ import { EncryptionConfig } from "@pithy-sh/secrets/src/crypto/envelope";
12
+ import { type DevSecretsFile, initialDevSecret } from "@pithy-sh/secrets/src/dev/devSecretsFile";
13
+ import { MASTER_KEY_BINDING } from "@pithy-sh/secrets/src/env/bindings";
14
+ import { SECRETS_CAPABILITY } from "@pithy-sh/secrets/src/manager/dispatcher";
15
+ import { mintSecretValue } from "@pithy-sh/secrets/src/mintValue";
16
+ import { initialMasterKeyConfig } from "@pithy-sh/secrets/src/provision/provisionSecrets";
17
+ import type { CloudflareAccountSelection } from "../cloudflare/config";
18
+ import { type EnsureSecretsStoreIdOptions, ensureSecretsStoreId } from "../cloudflare/storeId";
19
+ import { readBootstrapVars } from "../devSecrets/bootstrapVars";
20
+ import { readDevVarsSource } from "../devSecrets/devVars";
21
+ import { readDevSecrets, writeDevSecrets } from "../devSecrets/file";
22
+ import { resolveDevSecretsFile } from "../devSecrets/location";
23
+ import { ownProperties } from "../devSecrets/records";
24
+ import { renderDevSecretsNotes } from "../devSecrets/report";
25
+ import { type DevSecretsSeedReport, seedProjectDevSecrets } from "../devSecrets/seed";
26
+ import type { StatePathOptions } from "../notifier/state";
27
+
28
+ export interface AddBootstrapOptions {
29
+ /**
30
+ * The project root. A bootstrap value is recorded against the *project*, not a Worker, so one master
31
+ * key reaches every Worker's generated `.dev.vars` (#154) — a per-Worker mint would give a project with
32
+ * two Workers two divergent keys and orphan whichever secrets the loser encrypted.
33
+ */
34
+ projectDir: string;
35
+ /** The manifest of the capability just wired — the authority on which bindings it needs. */
36
+ manifest: CapabilityManifest;
37
+ /**
38
+ * Seam: seed the whole project's dev secrets. Defaults to the real seeder, with `reload` on.
39
+ *
40
+ * It is a seam because this function and the seeder can reach the *same* sentence in one run, and the
41
+ * only way to prove they say it once is to make both of them say it.
42
+ */
43
+ seed?: (projectDir: string) => Promise<DevSecretsSeedReport>;
44
+ /**
45
+ * Seam: resolve and record the account's `SECRETS_STORE_ID`. Defaults to the real one, which reaches
46
+ * Cloudflare — so a test that does not pass this would list an operator's real account.
47
+ */
48
+ ensureStoreId?: (options: EnsureSecretsStoreIdOptions) => Promise<string[]>;
49
+ /** Where the Pithy config directory is. Defaults to the real one; a seam so a test writes its own. */
50
+ paths?: StatePathOptions;
51
+ /**
52
+ * The Cloudflare account this project belongs to. One Secrets Store per account, so the store id
53
+ * recorded here belongs to whichever account the project uses (#206).
54
+ *
55
+ * **Required, with `null` the explicit "this project names none" (#234).** It was `account?:` until
56
+ * then, and its one caller — `capabilities/flow.ts` — omitted it, so `pithy add secrets` wrote the
57
+ * default account's `SECRETS_STORE_ID` into whichever `cloudflare.<name>.json` the project had
58
+ * named. An omission and a deliberate `null` are the same bytes at a call site, which is why the
59
+ * compiler is the only reviewer that can tell them apart.
60
+ */
61
+ account: CloudflareAccountSelection | null;
62
+ }
63
+
64
+ /**
65
+ * Finish `pithy add` for the values `add` itself cannot write, and say plainly what is left.
66
+ *
67
+ * Two kinds of gap, and only one of them is loud. A **binding** `add` cannot write — a Secrets Store
68
+ * entry, a Workflow, a Vectorize index — makes the Worker refuse its first request naming what is
69
+ * absent. A **registry secret** is read lazily, so the app boots healthy, `/health` is green, and the
70
+ * failure arrives at the first sign-in or the first tracked link as `secrets/not_found` on a name the
71
+ * adopter has never heard of. Both are handled here: where a *dev* value can be minted honestly, it is
72
+ * minted; where it cannot, the command names the provision command that creates it — at the moment the
73
+ * adopter is thinking about the capability rather than in a doc they read later.
74
+ *
75
+ * Returns the lines to print (`AddResult.notes`), in order. Nothing here ever prints a value.
76
+ */
77
+ export async function bootstrapAdd({
78
+ projectDir,
79
+ manifest,
80
+ seed,
81
+ ensureStoreId,
82
+ paths,
83
+ account,
84
+ }: AddBootstrapOptions): Promise<string[]> {
85
+ const notes: string[] = [];
86
+ // The account's Secrets Store id, resolved once and recorded in `<config>/cloudflare.json` — the one
87
+ // moment in its life anything asks Cloudflare where the store is (#182). Before the bindings loop,
88
+ // because the sentence it may print ("no credentials yet") is the same one that explains why the
89
+ // provisioning notes below are the next step. Never fatal: see {@link ensureSecretsStoreId}.
90
+ if (manifest.name === SECRETS_CAPABILITY) {
91
+ notes.push(...(await (ensureStoreId ?? ensureSecretsStoreId)({ paths: { ...paths, account } })));
92
+ }
93
+ for (const binding of manifest.requiredBindings) {
94
+ // Optional bindings are skipped for the same reason `validateBindings` skips them: nothing refuses
95
+ // a request over one, so a note would send the adopter provisioning what their app never asks for.
96
+ if (binding.optional || !isProvisionedBinding(binding.type)) continue;
97
+ if (isMasterKey(manifest, binding)) notes.push(...(await ensureDevMasterKey(projectDir)));
98
+ else notes.push(provisionNote(manifest.name, binding));
99
+ }
100
+ notes.push(...(await ensureDevSecrets(projectDir, manifest.devSecrets)));
101
+ // Last, and over the whole project rather than this capability: the value just minted has to reach the
102
+ // local `SECRETS` store to be worth anything, and so does every value already in the file that a
103
+ // previous run could not seed — `pithy add secrets` is exactly the run that makes the store openable.
104
+ // Never fatal. A project whose store is not wired yet gets a reason, not a failed `pithy add`.
105
+ //
106
+ // **`reload`, because this process is holding a stale config.** `pithy add` rewrote the Worker's
107
+ // `pithy.config.ts` several steps ago, and the module it imported before that write is the one still
108
+ // in the ESM cache — so the aggregate registry seeded against is the composition from *before* the
109
+ // add, and the secret this same run has just minted is not in it. It reached the store only on some
110
+ // later, unrelated command, which is what made it look like a store problem.
111
+ const seedProject = seed ?? ((dir: string) => seedProjectDevSecrets({ projectDir: dir, reload: true }));
112
+ notes.push(...renderDevSecretsNotes(await seedProject(projectDir)));
113
+ // **Deduplicated, in order.** Two `.dev.vars` writes happen in one run — the master key above, and
114
+ // the seeder's `cf-secrets-store` values — and both report delivery against the same Worker
115
+ // directories, so a Worker shadowing the project's file produces the same sentence twice. Both must
116
+ // be able to speak (a project that has not composed `secrets` has no targets, so only the first one
117
+ // does), and printing one sentence twice reads as two problems. An adopter counts lines.
118
+ return [...new Set(notes)];
119
+ }
120
+
121
+ /** The secrets capability's master-key binding — the one provisioned binding with an honest dev value. */
122
+ function isMasterKey(manifest: CapabilityManifest, binding: BindingSpec): boolean {
123
+ return manifest.name === SECRETS_CAPABILITY && binding.name === MASTER_KEY_BINDING;
124
+ }
125
+
126
+ /**
127
+ * What a binding needs that only provisioning can supply. `pithy <capability> provision` is the command
128
+ * in every case — each capability that owns provisioned resources subcommands its own name.
129
+ */
130
+ function provisionNote(capability: string, binding: BindingSpec): string {
131
+ return `${binding.name} is created by pithy ${capability} provision. Nothing local stands in for it.`;
132
+ }
133
+
134
+ /**
135
+ * Put this project's dev master key where every Worker's generated `.dev.vars` will pick it up, unless
136
+ * one is already recorded.
137
+ *
138
+ * **A fresh key per project, never a literal.** One key shipped in a template is one key across every
139
+ * adopter, and the first person to copy it into a deployed environment hands everyone else their
140
+ * secrets. `initialMasterKeyConfig` generates one.
141
+ *
142
+ * **Only when absent** — the same rule `ensureMasterKey` states for the provisioned key. Replacing the
143
+ * key orphans every secret already stored under it, and re-running `pithy add secrets` must be a no-op.
144
+ * An *empty* value is treated as absent: nothing could have been encrypted under it, so there is
145
+ * nothing to orphan, and leaving it would leave `pithy dev` refusing every request.
146
+ *
147
+ * **A key in a pre-#154 project's root `.dev.vars` is adopted, never re-minted.** That file used to be
148
+ * the store, and it is the one value whose replacement cannot be undone: every secret already encrypted
149
+ * under the old key becomes unreadable, with no error that names the cause. So the old key is carried
150
+ * into the bootstrap store as it stands, and the project keeps reading what it already wrote. Their file
151
+ * is not rewritten — nothing here ever rewrites an adopter's `.dev.vars`.
152
+ */
153
+ async function ensureDevMasterKey(projectDir: string): Promise<string[]> {
154
+ const path = await resolveDevSecretsFile(projectDir);
155
+ if ((await readDevSecrets(path))[MASTER_KEY_BINDING]) {
156
+ return [`${MASTER_KEY_BINDING} is already in ${path}. Left as it is — a new key orphans every stored secret.`];
157
+ }
158
+ // Both older homes, in the order they were used: `dev.json` since #154, the project root's `.dev.vars`
159
+ // before it. Adopted rather than re-minted — this is the one value whose replacement cannot be undone,
160
+ // because every secret already encrypted under the old key becomes unreadable with no error that names
161
+ // the cause. Their `.dev.vars` is not rewritten; nothing here ever rewrites an adopter's.
162
+ //
163
+ // The `.dev.vars` read goes through the writer's own reader, where only `ENOENT` is "no key here".
164
+ // `.catch(() => "")` answered that for every errno, so an unreadable file read as absent and this
165
+ // minted a second key. See {@link readDevVarsSource}.
166
+ const recorded = (await readBootstrapVars(projectDir))[MASTER_KEY_BINDING];
167
+ const stranded = parseDevVars((await readDevVarsSource(join(projectDir, ".dev.vars"))) ?? "")[MASTER_KEY_BINDING];
168
+ const adopted = firstValue(recorded, stranded);
169
+ // The file states the `EncryptionConfig` itself, and nothing around it (#323) — the binding carries the
170
+ // bare config, and the file states the payload its destination receives. A hand-edit that breaks it is
171
+ // caught by the registry's own schema, naming the secret, rather than by a Worker answering every
172
+ // request with `secrets/crypto_failed`. An adopted value arrives as the string a binding carried, so it
173
+ // is parsed back into the object the file holds — which is now the same object, unwrapped, either way.
174
+ const config = adopted === undefined ? await initialMasterKeyConfig() : parseConfig(adopted);
175
+ if (config === null) {
176
+ return [
177
+ `${MASTER_KEY_BINDING} is set on this machine but is not a valid master key, so nothing was adopted.`,
178
+ `Put a valid one in ${path}, or delete it and run pithy add secrets again to mint a new one — a new key orphans every secret the old one encrypted.`,
179
+ ];
180
+ }
181
+ const wrote = await writeDevSecrets(path, { [MASTER_KEY_BINDING]: initialDevSecret(masterKeyRegistryEntry, config) });
182
+ if (wrote.length === 0) return [];
183
+ if (adopted !== undefined) {
184
+ return [
185
+ `Adopted the ${MASTER_KEY_BINDING} this machine already had, into ${path}. A new key would orphan every secret the old one encrypted.`,
186
+ ];
187
+ }
188
+ return [
189
+ `Minted a dev master key as ${MASTER_KEY_BINDING}, into ${path}. Local only, and it reaches each Worker's generated .dev.vars.`,
190
+ "Deployed environments get theirs from pithy secrets provision.",
191
+ ];
192
+ }
193
+
194
+ /** The first non-empty of the older homes, or `undefined` when this machine has no key at all. */
195
+ function firstValue(...candidates: (string | undefined)[]): string | undefined {
196
+ for (const candidate of candidates) if (candidate !== undefined && candidate !== "") return candidate;
197
+ return undefined;
198
+ }
199
+
200
+ /** A binding's string parsed back into the `EncryptionConfig` the file states, or `null` when it is not one. */
201
+ function parseConfig(value: string): EncryptionConfig | null {
202
+ try {
203
+ const parsed = EncryptionConfig.safeParse(JSON.parse(value));
204
+ return parsed.success ? parsed.data : null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Mint a dev value for every secret this capability declares as generatable, unless one is already there.
212
+ *
213
+ * **The capability decides, not this file.** Each value comes from a `devSecrets` entry the capability
214
+ * ships in its own manifest, mirroring the `devValue` on the registry entry that owns the secret. A
215
+ * list of names here would drift the moment a capability shipped another one — and drift silently,
216
+ * because a missing lazily-read secret is invisible until the code path that reads it runs.
217
+ *
218
+ * **The secrets file, not `.dev.vars` (#149), and that file is outside the checkout (#156).** Only the
219
+ * destination changed; the declaration and the minting are the ones `pithy add` has always done.
220
+ * `.dev.vars` is wrangler's file — env bindings, `UPPER_SNAKE` — and a kebab secret name sitting in it
221
+ * taught every adopter that one of the two conventions was a mistake. The value is written as a full
222
+ * version-1 envelope, which is the shape the store actually holds, so dev stops being a shape
223
+ * production never sees. **Every note here names the absolute path**, because nothing in the project
224
+ * does: "already in the secrets file" is not actionable if the reader cannot open it.
225
+ *
226
+ * **Only when the secrets file lacks it.** A session secret replaced is every live session invalidated;
227
+ * a link-signing key replaced is every link already in an inbox broken.
228
+ *
229
+ * **A copy in `.dev.vars` no longer counts as having it (#153).** It used to: dev resolved every secret
230
+ * from its injected binding, so minting beside one produced two live values with nothing to say which
231
+ * signed what, and the honest answer was to refuse and say where it belonged. Dev reads the seeded row
232
+ * now, so that line signs nothing — refusing over it would leave the Worker with no session key at all,
233
+ * which is the failure this whole function exists to prevent. So the mint happens and the note names the
234
+ * stranded line. Nothing rewrites their `.dev.vars`; the value is still there if they want it, and
235
+ * `pithy doctor` repeats it every run until it is deleted.
236
+ *
237
+ * One write for the whole set, so a capability declaring several either lands them all or lands none.
238
+ */
239
+ async function ensureDevSecrets(projectDir: string, declared: readonly DevSecret[]): Promise<string[]> {
240
+ if (declared.length === 0) return [];
241
+ // Prototype-free: a secret named `constructor` must not read back `Object.prototype.constructor` and
242
+ // be reported as sitting in a `.dev.vars` the project does not have. See {@link ownProperties}.
243
+ //
244
+ // And read honestly: `.catch(() => "")` on an unreadable file said "nothing here" for every errno, so
245
+ // a stranded line went unmentioned. It costs a sentence, not a value. See {@link readDevVarsSource}.
246
+ const source = (await readDevVarsSource(join(projectDir, ".dev.vars"))) ?? "";
247
+ const inDevVars = ownProperties(parseDevVars(source));
248
+ const path = await resolveDevSecretsFile(projectDir);
249
+ const stated = await readDevSecrets(path);
250
+ const minted: DevSecretsFile = {};
251
+ const notes: string[] = [];
252
+ for (const secret of declared) {
253
+ if (stated[secret.name]) {
254
+ notes.push(
255
+ `${secret.name} is already in ${path}. Left as it is — a new value invalidates what the old one signed.`,
256
+ );
257
+ continue;
258
+ }
259
+ // Not a bootstrap secret, and that is a fact rather than an assumption: `defineSecretRegistry`
260
+ // refuses `bootstrap` beside `devValue`, and everything here is a manifest-declared mintable value.
261
+ minted[secret.name] = initialDevSecret({}, mintSecretValue(secret.devValue));
262
+ notes.push(
263
+ `Minted a dev ${secret.name} into ${path}. Local only.`,
264
+ `Deployed environments need pithy secrets create ${secret.name}.`,
265
+ );
266
+ const stranded = inDevVars[secret.name];
267
+ if (stranded !== undefined && stranded !== "") {
268
+ notes.push(
269
+ `${secret.name} is also in .dev.vars, which dev no longer reads. Nothing was rewritten — delete that line, or move its value into ${path} as { "currentVersion": "1", "versions": { "1": <value> } }.`,
270
+ );
271
+ }
272
+ }
273
+ await writeDevSecrets(path, minted);
274
+ return notes;
275
+ }