@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,331 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { cp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join, relative, sep } from "node:path";
6
+ import { ConflictError, InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { promoteDependencies } from "../project/packageManager";
8
+ import { readOptionalFile } from "../project/readOptionalFile";
9
+ import { ensureScaffoldPath, pathExists } from "../project/scaffold";
10
+ import { workerEntryPath } from "../project/wrangler";
11
+ import {
12
+ capabilityImportSpecifier,
13
+ findNamedImport,
14
+ importedSpecifiers,
15
+ isCapabilityImport,
16
+ isInside,
17
+ namedReexports,
18
+ } from "./configImports";
19
+
20
+ /**
21
+ * The directory an ejected capability's source is copied into, relative to the Worker's
22
+ * `pithy.config.ts` — so it lands in `apps/<worker>/capabilities/<cap>/`, beside the config that
23
+ * imports it. Ejecting is per-Worker because the wiring it forks is per-Worker.
24
+ */
25
+ export const EJECT_DIR = "capabilities";
26
+
27
+ /**
28
+ * The local import path an ejected capability is wired to, **from the Worker's own directory** — also the
29
+ * "this is ejected" signal for upgrade, which reads `pithy.config.ts` and nothing else.
30
+ *
31
+ * Every other file is a different distance from the fork, so a file that is not the config asks
32
+ * {@link ejectSpecifierFromDir} instead.
33
+ */
34
+ export function ejectImportPath(capability: string): string {
35
+ return `./${EJECT_DIR}/${capability}`;
36
+ }
37
+
38
+ /**
39
+ * The fork's specifier **as written into a file in `fromDir`** — `./capabilities/<cap>` from the Worker's
40
+ * own directory, `../capabilities/<cap>` from the `src/` the entry usually sits in.
41
+ *
42
+ * A specifier is resolved relative to the file holding it, and eject writes one into two files at two
43
+ * depths: `apps/<worker>/pithy.config.ts` and whatever module that Worker's `main` names. Reusing the
44
+ * config's spelling in the entry produced `./capabilities/<cap>` resolved from `apps/<worker>/src/`,
45
+ * which is nowhere — a Worker that stopped bundling, written by the command whose job is that it still
46
+ * does. So each writer derives its own path from the file it is writing into.
47
+ *
48
+ * POSIX separators whatever the platform's are: this is a module specifier, not a filesystem path, and a
49
+ * bundler reads `\` as an escape.
50
+ */
51
+ export function ejectSpecifierFromDir(workerDir: string, fromDir: string, capability: string): string {
52
+ const path = relative(fromDir, join(workerDir, EJECT_DIR, capability))
53
+ .split(sep)
54
+ .join("/");
55
+ return path.startsWith(".") ? path : `./${path}`;
56
+ }
57
+
58
+ /**
59
+ * The capabilities a `pithy.config.ts` source has ejected — those imported from the local
60
+ * `./capabilities/<name>` path rather than an `@pithy-sh/*` package. The local import **is** the
61
+ * ejected signal: a future `pithy upgrade` reads this to skip forks (issue #33), since ejected code
62
+ * no longer tracks the package.
63
+ */
64
+ export function parseEjectedCapabilities(configSource: string): string[] {
65
+ const names: string[] = [];
66
+ for (const specifier of importedSpecifiers(configSource)) {
67
+ const name = ejectedCapabilityName(specifier);
68
+ if (name && !names.includes(name)) names.push(name);
69
+ }
70
+ return names;
71
+ }
72
+
73
+ /**
74
+ * The capability a specifier forks, or `undefined` when it does not point into the fork directory.
75
+ *
76
+ * Decided by {@link isInside} — the same call `importOrigin` makes — because these two functions
77
+ * answer one question and used to answer it differently: a regex here accepted
78
+ * `./capabilities/<name>/<anything>` while `isCapabilityImport` demanded exact equality, so a config
79
+ * read as ejected while the import that made it so was refused as not the capability's. The regex also
80
+ * captured `..` as a capability name, off a path leaving the directory entirely.
81
+ */
82
+ function ejectedCapabilityName(specifier: string): string | undefined {
83
+ const prefix = `./${EJECT_DIR}/`;
84
+ if (!specifier.startsWith(prefix)) return undefined;
85
+ const name = specifier.slice(prefix.length).split("/")[0];
86
+ if (!name || name === "." || name === "..") return undefined;
87
+ return isInside(specifier, ejectImportPath(name)) ? name : undefined;
88
+ }
89
+
90
+ /**
91
+ * Read a Worker's `pithy.config.ts` and return the ejected capability names (empty if the file is
92
+ * unreadable). `workerDir` is `apps/<name>` — the config that wires the capability is the one that says
93
+ * whether it was forked.
94
+ */
95
+ export async function ejectedCapabilities(workerDir: string): Promise<string[]> {
96
+ try {
97
+ return parseEjectedCapabilities(await readFile(join(workerDir, "pithy.config.ts"), "utf8"));
98
+ } catch {
99
+ return [];
100
+ }
101
+ }
102
+
103
+ /** Whether a specific capability has been ejected in this Worker. */
104
+ export async function isEjected(workerDir: string, capability: string): Promise<boolean> {
105
+ return (await ejectedCapabilities(workerDir)).includes(capability);
106
+ }
107
+
108
+ /** Promote a forked capability's runtime deps into the project. Injected in tests. */
109
+ export type PromoteDeps = (projectDir: string, deps: string[]) => Promise<void>;
110
+
111
+ export interface EjectCapabilityOptions {
112
+ /** The project root — where `package.json`, the lockfile, and `node_modules` live. */
113
+ projectDir: string;
114
+ /** The Worker's directory (`apps/<name>`) — its `pithy.config.ts`, and where the fork lands. */
115
+ workerDir: string;
116
+ /** The capability's short name, e.g. `auth` — the local directory and import leaf. */
117
+ capability: string;
118
+ /** The capability's package, e.g. `@pithy-sh/auth` — the source and manifest to read. */
119
+ package: string;
120
+ /** Overwrite an existing local copy, discarding the user's edits. Off by default (refuses). */
121
+ force?: boolean;
122
+ /** Promote the package's runtime deps; defaults to the detected package manager. */
123
+ promoteDeps?: PromoteDeps;
124
+ }
125
+
126
+ /** What eject did: where the source landed and which deps it promoted. */
127
+ export interface EjectResult {
128
+ capability: string;
129
+ /** The Worker-relative directory the source was copied into (`capabilities/<cap>`). */
130
+ path: string;
131
+ /** The `name@version` deps promoted into the project (workspace-internal ones excluded). */
132
+ promotedDependencies: string[];
133
+ /** Whether an existing local copy was overwritten (`--force`). */
134
+ forced: boolean;
135
+ }
136
+
137
+ /**
138
+ * The package's promotable runtime dependencies as `name@version`, read from the installed manifest.
139
+ * Workspace-internal versions (`workspace:*`) are dropped — they exist only inside this monorepo and a
140
+ * published package never carries them; an adopter's install resolves them to real ranges.
141
+ */
142
+ async function promotableDependencies(projectDir: string, pkg: string): Promise<string[]> {
143
+ // Absent and corrupt are both answered by reinstalling; a mode bit and a directory are not, and one
144
+ // `try` around the read and the parse said *reinstall* to all four (#217). `readOptionalFile` owns the
145
+ // errno that is not absence and hedges on it, which leaves each of the other three its own sentence.
146
+ const path = join(projectDir, "node_modules", pkg, "package.json");
147
+ const raw = await readOptionalFile(path);
148
+ if (raw === null) {
149
+ throw new InternalError({
150
+ message: `${pkg} is not installed, so its dependencies cannot be promoted.`,
151
+ action: `Reinstall ${pkg}, then eject again.`,
152
+ detail: `No package.json at ${path}.`,
153
+ });
154
+ }
155
+
156
+ let dependencies: Record<string, string>;
157
+ try {
158
+ dependencies = (JSON.parse(raw) as { dependencies?: Record<string, string> }).dependencies ?? {};
159
+ } catch (cause) {
160
+ throw new InternalError({
161
+ message: `${pkg}'s package.json is not valid JSON, so its dependencies cannot be promoted.`,
162
+ action: `Reinstall ${pkg}, then eject again.`,
163
+ detail: cause instanceof Error ? cause.message : String(cause),
164
+ });
165
+ }
166
+ return Object.entries(dependencies)
167
+ .filter(([, version]) => !version.startsWith("workspace:"))
168
+ .map(([name, version]) => `${name}@${version}`);
169
+ }
170
+
171
+ /**
172
+ * Repoint the managed-region import from the package to the local copy; idempotent if already local.
173
+ *
174
+ * The import is found by its binding and repointed whatever path into the package it uses — the same
175
+ * set `add` writes and `remove` takes out. Matching one exact specifier meant a hand-edited deep
176
+ * import could not be ejected at all, and `pithy add` would not put the canonical line back, so there
177
+ * was no way out through the CLI.
178
+ */
179
+ async function repointImport(workerDir: string, pkg: string, capability: string): Promise<void> {
180
+ const path = join(workerDir, "pithy.config.ts");
181
+ const source = await readFile(path, "utf8");
182
+ // Derived from the file being written, like the entry's is. For the config the answer is
183
+ // `ejectImportPath` itself — it sits in the Worker's own directory — and that is the string
184
+ // `parseEjectedCapabilities` reads back as the "this is ejected" signal.
185
+ const local = ejectSpecifierFromDir(workerDir, dirname(path), capability);
186
+ const found = findNamedImport(source, capability);
187
+ if (found?.specifier === local) return; // already ejected — a --force re-copy leaves it local
188
+ if (found && isCapabilityImport(found.specifier, pkg, local)) {
189
+ // The specifier is the only quoted region in the statement, so swapping it there keeps whatever
190
+ // spacing and quote style the adopter's config uses.
191
+ const repointed = found.statement.replace(found.specifier, () => local);
192
+ await writeFile(
193
+ path,
194
+ source.replace(found.statement, () => repointed),
195
+ );
196
+ return;
197
+ }
198
+ throw new NotFoundError({
199
+ message: `${path} doesn't import ${pkg}.`,
200
+ action: `Run pithy add ${capability} first, then eject.`,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The local path a specifier into the package becomes once the package's `src/` is a fork under
206
+ * `capabilities/<cap>/`.
207
+ *
208
+ * The barrel becomes the fork directory itself — `local`, the fork as the file being written reaches it,
209
+ * so the two halves of the wiring name one directory in each file's own spelling. Anything deeper under
210
+ * `src/` keeps its path below it, because `cp` preserved the structure.
211
+ *
212
+ * Anything else inside the package is **refused, by name**. Only `src/` is copied, so there is no local
213
+ * counterpart to point at, and quietly leaving the line alone would put the package's class back into the
214
+ * bundle under the fork's name — which is the failure repointing exists to prevent.
215
+ */
216
+ function localSpecifier(pkg: string, local: string, specifier: string): string {
217
+ if (specifier === pkg || specifier === capabilityImportSpecifier(pkg)) return local;
218
+ const src = `${pkg}/src/`;
219
+ if (specifier.startsWith(src)) return `${local}/${specifier.slice(src.length)}`;
220
+ throw new ConflictError({
221
+ message: `The worker entry re-exports ${specifier}, which eject cannot fork — only ${pkg}/src is copied.`,
222
+ action: `Point that export at a path under ${pkg}/src, or take the line out, then eject again.`,
223
+ });
224
+ }
225
+
226
+ /**
227
+ * The Worker entry with its re-exports of the package repointed at the local fork, or `null` when there
228
+ * is nothing to change.
229
+ *
230
+ * **The other half of the wiring, and the half that decides which code actually runs.** `pithy add` writes
231
+ * `export { <Class> } from "@pithy-sh/<cap>/…"` into the entry, because wrangler resolves a Durable
232
+ * Object's `class_name` against the module `main` names (#428). Ejecting only `pithy.config.ts` left that
233
+ * line pointing at the package: Cloudflare instantiated the package's class while the adopter edited the
234
+ * copy, and nothing said so — it builds, it deploys, and every change to the forked actor is ignored.
235
+ * `docs/EJECT.md` promised the project imports nothing from the package afterwards, and this is what makes
236
+ * that sentence true rather than nearly true.
237
+ *
238
+ * Every re-export pointing into the package, not only the ones the CLI wrote. A line an adopter added by
239
+ * hand reaches the same class through the same package, and `repointImport` follows whatever path into the
240
+ * package a config uses for exactly this reason.
241
+ *
242
+ * A Worker whose config names no entry — a front end that joins the dev set through `pithy.worker.jsonc`
243
+ * alone — has nothing to repoint and is not a failure.
244
+ */
245
+ async function planEntryExports(
246
+ workerDir: string,
247
+ pkg: string,
248
+ capability: string,
249
+ ): Promise<{ path: string; written: string } | null> {
250
+ const path = await workerEntryPath(workerDir).catch(() => null);
251
+ const source = path === null ? null : await readOptionalFile(path);
252
+ if (path === null || source === null) return null;
253
+
254
+ // Relative to the entry, not to `pithy.config.ts`: the two files sit at different depths and the fork
255
+ // is one directory. {@link ejectSpecifierFromDir} says what reusing the config's spelling here cost.
256
+ const local = ejectSpecifierFromDir(workerDir, dirname(path), capability);
257
+ // Planned as spans and applied last-first, so each splice lands where the scanner found it and no
258
+ // earlier edit moves a later one. Found by offset because a commented-out copy of a line contains
259
+ // that line verbatim, and a literal search repointed the comment while the live export kept naming
260
+ // the package (#428). `namedReexports` yields in source order, so reversing gives descending starts.
261
+ const edits: { start: number; end: number; text: string }[] = [];
262
+ for (const { statement, specifier, start } of namedReexports(source)) {
263
+ if (specifier !== pkg && !specifier.startsWith(`${pkg}/`)) continue;
264
+ // The specifier is the only quoted region in the statement, so swapping it there keeps whatever
265
+ // spacing and quote style the entry uses. Replacement functions keep any `$` in either string.
266
+ const text = statement.replace(specifier, () => localSpecifier(pkg, local, specifier));
267
+ edits.push({ start, end: start + statement.length, text });
268
+ }
269
+ if (edits.length === 0) return null;
270
+
271
+ let written = source;
272
+ for (const edit of edits.reverse()) {
273
+ written = written.slice(0, edit.start) + edit.text + written.slice(edit.end);
274
+ }
275
+ return written === source ? null : { path, written };
276
+ }
277
+
278
+ /**
279
+ * Eject a capability into **one Worker**: copy its entire installed `src/` into
280
+ * `apps/<worker>/capabilities/<cap>/`, repoint that Worker's `pithy.config.ts` import at the local copy,
281
+ * and promote the package's runtime deps into the project so the copy builds standalone. The capability
282
+ * is now the user's — nothing from `@pithy-sh/<cap>` is imported and it no longer upgrades (the
283
+ * principle-3 trade). Another Worker that wires the same capability keeps the package; a fork is scoped
284
+ * to the Worker that asked for it. Refuses to overwrite an existing local copy unless `force` is set;
285
+ * `force` removes it first so stale files don't linger.
286
+ */
287
+ export async function ejectCapability(options: EjectCapabilityOptions): Promise<EjectResult> {
288
+ const { projectDir, workerDir, capability, package: pkg, force } = options;
289
+ const source = join(projectDir, "node_modules", pkg, "src");
290
+ const dest = join(workerDir, EJECT_DIR, capability);
291
+
292
+ if (!(await pathExists(source))) {
293
+ throw new NotFoundError({
294
+ message: `${pkg} is not installed (no ${pkg}/src to eject).`,
295
+ action: `Run pithy add ${capability} first.`,
296
+ });
297
+ }
298
+
299
+ // The fork's own path, and every segment of it Pithy composed: `apps`, `apps/<worker>`,
300
+ // `capabilities`, `capabilities/<cap>`. This was a local `exists()` over `stat`, which follows a link
301
+ // and answers about its destination — so a symlink at any of the four read as "not ejected yet" and the
302
+ // `cp` below wrote the capability's whole source through it, outside the project. That is the fifth
303
+ // producer of one escape, and it is the reason the question has exactly one implementation now.
304
+ await ensureScaffoldPath(projectDir, dest);
305
+ const alreadyEjected = await pathExists(dest);
306
+ if (alreadyEjected && !force) {
307
+ throw new ConflictError({
308
+ message: `${EJECT_DIR}/${capability} already exists.`,
309
+ action: "Edit the local copy, or re-run with --force to overwrite it (discards your changes).",
310
+ });
311
+ }
312
+ if (alreadyEjected) await rm(dest, { recursive: true, force: true });
313
+
314
+ // Order matters for a clean failure: copy the source, promote its deps, and only then repoint the
315
+ // config. If promotion fails, the config still imports the working package rather than a local copy
316
+ // whose dependencies were never installed.
317
+ await cp(source, dest, { recursive: true });
318
+
319
+ const promotedDependencies = await promotableDependencies(projectDir, pkg);
320
+ const promote = options.promoteDeps ?? ((dir, deps) => promoteDependencies(dir, deps).then(() => {}));
321
+ await promote(projectDir, promotedDependencies);
322
+
323
+ // Planned before either write, and written after both: {@link localSpecifier} refuses a re-export it
324
+ // cannot fork, and a refusal raised between the two would leave the config naming the fork while the
325
+ // entry still named the package. Same reasoning as the copy/promote/repoint order above.
326
+ const entry = await planEntryExports(workerDir, pkg, capability);
327
+ await repointImport(workerDir, pkg, capability);
328
+ if (entry) await writeFile(entry.path, entry.written);
329
+
330
+ return { capability, path: `${EJECT_DIR}/${capability}`, promotedDependencies, forced: Boolean(alreadyEjected) };
331
+ }
@@ -0,0 +1,346 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile, unlink, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
8
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import type { LocaleCatalogs } from "@pithy-sh/core/src/i18n/catalog";
10
+ import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
11
+ import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
12
+ import { email_0001_suppressions } from "@pithy-sh/email/src/migrations/0001_suppressions";
13
+ import {
14
+ bounceRoutingRuleName,
15
+ type EmailDeprovisioner,
16
+ type EmailProvisioner,
17
+ emailWorkerName,
18
+ suppressionDatabaseName,
19
+ } from "@pithy-sh/email/src/provision/provisionEmail";
20
+ import { type EmailWorkerWranglerTemplate, resolveEmailConfig } from "@pithy-sh/email/src/provision/resolveEmailConfig";
21
+ import type { EmailTheme } from "@pithy-sh/email/src/templates/theme";
22
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
23
+ import { parse } from "comment-json";
24
+ import type { MigrationProvider } from "kysely/migration";
25
+ import type { CliAuditEmit } from "../audit/cliAudit";
26
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
27
+ import { runWrangler } from "../project/wrangler";
28
+
29
+ /** The suppression migration set, as provisioning runs it against the shared suppression D1. */
30
+ function suppressionMigrationProvider(): MigrationProvider {
31
+ const registry = createMigrationRegistry([
32
+ {
33
+ database: "emailSuppressions",
34
+ namespace: "email",
35
+ order: 100,
36
+ migrations: { "0001_suppressions": email_0001_suppressions },
37
+ },
38
+ ]);
39
+ const provider = registry.emailSuppressions;
40
+ if (!provider) throw new Error("missing email suppression migration provider");
41
+ return provider;
42
+ }
43
+
44
+ /** The per-environment resource ids + base URL the email worker deploy needs, resolved by the caller. */
45
+ export interface EmailEnvResources {
46
+ /** The app database id for this environment — where jobs/events live. */
47
+ appDatabaseId: string;
48
+ /** This environment's secrets database id (`<project>-<env>-secrets`) — holds the signing key. */
49
+ secretsDatabaseId: string;
50
+ /** The app worker's public base URL for this environment — callback links are built against it. */
51
+ baseUrl: string;
52
+ }
53
+
54
+ /** Resolve the per-environment resources for the email worker (from the project wrangler + name lookups). */
55
+ export type ResolveEmailEnv = (env: ManagedEnvironment) => Promise<EmailEnvResources>;
56
+
57
+ export interface CloudflareEmailProvisionerOptions {
58
+ cf: CloudflareClients;
59
+ /**
60
+ * The account this provisions into, and what vouches for it (#378).
61
+ *
62
+ * Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
63
+ * already held while a find-or-create read an empty listing as "this account has none" and minted a
64
+ * real resource in whichever account the shell had named. The id is still here — `account.accountId` —
65
+ * and it now travels with the answer to "who says so".
66
+ */
67
+ account: ConfirmedAccount;
68
+ /**
69
+ * The project name, from `requireProjectName(await loadProject(projectDir))` — never
70
+ * `resolveProjectName`. The worker, both Workflows, the suppression database, and the inbound routing
71
+ * rule all lead with it, and the suppression database is *found by name and reused*: a guessed value
72
+ * would adopt another project's opt-out list.
73
+ */
74
+ project: string;
75
+ /** The broad bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
76
+ apiToken: string;
77
+ /** The CF Secrets Store id holding the per-env master keys (the email worker decrypts its signing key). */
78
+ storeId: string;
79
+ /** The resolved brand theme (from the app's `email()` config), serialized into the worker's `EMAIL_THEME` var. */
80
+ theme: EmailTheme;
81
+ /**
82
+ * The project's email catalogs, from the composed capabilities' `hostCatalogs()` — serialized into
83
+ * the worker's `EMAIL_MESSAGES` var.
84
+ *
85
+ * The same journey the theme makes, because the host worker composes nothing and both are things
86
+ * only a provision run can hand it. Empty for a project that composed no i18n capability, and then
87
+ * no var is written.
88
+ */
89
+ messages: LocaleCatalogs;
90
+ /** Resolve the per-env app DB id, secrets DB id, and base URL — injected so it is testable + decoupled. */
91
+ resolveEnv: ResolveEmailEnv;
92
+ /**
93
+ * Optional inbound routing: the zone, the address to match, and the production app worker to deliver to.
94
+ * Absent → the routing step is skipped (the operator wires it later, on a subdomain that won't disturb
95
+ * the apex MX). Email Routing must already be enabled on the zone.
96
+ */
97
+ routing?: { zoneId: string; address: string; appWorkerName: string };
98
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
99
+ audit?: CliAuditEmit;
100
+ }
101
+
102
+ /**
103
+ * The live {@link EmailProvisioner} — the CF + wrangler implementation of email provisioning. The
104
+ * control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: CF API only via that client) and are
105
+ * each idempotent; the worker deploy shells out to wrangler with the bootstrap token. No CF API token is
106
+ * minted — the email worker sends through the `send_email` binding and reads its signing key through the
107
+ * `SECRETS` bindings, neither of which uses a token. The live steps are exercised by the integration suite.
108
+ */
109
+ export class CloudflareEmailProvisioner implements EmailProvisioner {
110
+ readonly #cf: CloudflareClients;
111
+ readonly #account: ConfirmedAccount;
112
+ readonly #project: string;
113
+ readonly #apiToken: string;
114
+ readonly #storeId: string;
115
+ readonly #theme: EmailTheme;
116
+ readonly #messages: LocaleCatalogs;
117
+ readonly #resolveEnv: ResolveEmailEnv;
118
+ readonly #routing?: { zoneId: string; address: string; appWorkerName: string };
119
+ readonly #audit: CliAuditEmit;
120
+
121
+ constructor(options: CloudflareEmailProvisionerOptions) {
122
+ this.#cf = options.cf;
123
+ this.#account = options.account;
124
+ this.#project = options.project;
125
+ this.#apiToken = options.apiToken;
126
+ this.#storeId = options.storeId;
127
+ this.#theme = options.theme;
128
+ this.#messages = options.messages;
129
+ this.#resolveEnv = options.resolveEnv;
130
+ this.#routing = options.routing;
131
+ this.#audit = options.audit ?? (async () => {});
132
+ }
133
+
134
+ /** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy the Workflow-hosting worker. */
135
+ async preflight(): Promise<void> {
136
+ if (!(await this.#cf.workers().accountSubdomain())) {
137
+ throw new ValidationError({
138
+ message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
139
+ action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
140
+ });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Reuse the project's suppression D1 if it exists, otherwise create it.
146
+ *
147
+ * Find-then-create is safe only because the name carries the project. D1's namespace is
148
+ * account-wide, so the old fixed `pithy-email-suppressions` meant a second, unrelated product in the
149
+ * same account silently inherited the first's opt-out list — one product's unsubscribe suppressing
150
+ * another's transactional mail. D1 exposes no tags through the API, so the name is the whole
151
+ * ownership record, and the audit event writes the project down beside it.
152
+ *
153
+ * Which is also why the miss refuses on an unconfirmed account (#378): "no database of that name" and
154
+ * "I asked an account nobody claims" arrive as the same empty listing, and creating on the second one
155
+ * puts this project's opt-out list in somebody else's account.
156
+ */
157
+ async ensureSuppressionDatabase(): Promise<{ databaseId: string }> {
158
+ const name = suppressionDatabaseName(this.#project);
159
+ const existing = await findOnConfirmedAccount({
160
+ ...this.#account,
161
+ what: `the ${name} database`,
162
+ find: () => this.#cf.d1Provisioner().findDatabaseByName(name),
163
+ });
164
+ if (existing) return { databaseId: existing.uuid };
165
+ const db = await this.#cf.d1Provisioner().createDatabase(name);
166
+ await this.#audit({
167
+ environment: "global",
168
+ action: "email/suppression_db_created",
169
+ outcome: "success",
170
+ severity: "info",
171
+ resourceType: "cf_d1",
172
+ resourceId: db.uuid,
173
+ metadata: { name },
174
+ });
175
+ return { databaseId: db.uuid };
176
+ }
177
+
178
+ /** Run the suppression migration against the shared D1 over REST (idempotent — applied ones skip). */
179
+ async migrateSuppression(databaseId: string): Promise<void> {
180
+ await runMigrations(this.#cf.d1(databaseId), suppressionMigrationProvider());
181
+ }
182
+
183
+ /** Resolve the env's wrangler config from the committed template + provisioned ids, then `wrangler deploy`. */
184
+ async deployWorker(env: ManagedEnvironment, suppressionDatabaseId: string): Promise<void> {
185
+ const { appDatabaseId, secretsDatabaseId, baseUrl } = await this.#resolveEnv(env);
186
+ const dir = emailWorkerDir();
187
+ const template = parse(
188
+ await readFile(join(dir, "wrangler.jsonc"), "utf8"),
189
+ ) as unknown as EmailWorkerWranglerTemplate;
190
+ const config = resolveEmailConfig(template, {
191
+ project: this.#project,
192
+ env,
193
+ appDatabaseId,
194
+ suppressionDatabaseId,
195
+ secretsDatabaseId,
196
+ storeId: this.#storeId,
197
+ baseUrl,
198
+ theme: this.#theme,
199
+ messages: this.#messages,
200
+ });
201
+ const configPath = join(dir, `.wrangler.${env}.json`);
202
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
203
+ try {
204
+ await runWrangler(["deploy", "--config", configPath], {
205
+ cwd: dir,
206
+ env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#account.accountId },
207
+ });
208
+ await this.#audit({
209
+ environment: env,
210
+ action: "email/worker_deployed",
211
+ outcome: "success",
212
+ severity: "info",
213
+ resourceType: "cf_worker",
214
+ resourceId: emailWorkerName(this.#project, env),
215
+ });
216
+ } catch (error) {
217
+ await this.#audit({
218
+ environment: env,
219
+ action: "email/worker_deployed",
220
+ outcome: "failure",
221
+ severity: "info",
222
+ resourceType: "cf_worker",
223
+ resourceId: emailWorkerName(this.#project, env),
224
+ });
225
+ throw error;
226
+ } finally {
227
+ await unlink(configPath).catch(() => {});
228
+ }
229
+ }
230
+
231
+ /** Create the inbound routing rule (production app worker) when routing is configured; otherwise skip. */
232
+ async ensureRoutingRule(): Promise<{ created: boolean; skipped: boolean }> {
233
+ if (!this.#routing) return { created: false, skipped: true };
234
+ const { created } = await this.#cf.emailRouting().ensureWorkerRoute({
235
+ zoneId: this.#routing.zoneId,
236
+ address: this.#routing.address,
237
+ workerName: this.#routing.appWorkerName,
238
+ // Project-scoped, and distinct from `@pithy-sh/support`'s inbound rule. `ensureWorkerRoute` keys
239
+ // idempotency on the rule name, so two projects sharing a zone and an unscoped name would each
240
+ // believe the other's rule was their own — and one project's bounce mail would be delivered to
241
+ // the other project's Worker.
242
+ ruleName: bounceRoutingRuleName(this.#project),
243
+ });
244
+ if (created) {
245
+ await this.#audit({
246
+ environment: "global",
247
+ action: "email/routing_rule_created",
248
+ outcome: "success",
249
+ severity: "info",
250
+ resourceType: "cf_email_routing_rule",
251
+ resourceId: this.#routing.address,
252
+ metadata: {
253
+ zoneId: this.#routing.zoneId,
254
+ address: this.#routing.address,
255
+ workerName: this.#routing.appWorkerName,
256
+ ruleName: bounceRoutingRuleName(this.#project),
257
+ },
258
+ });
259
+ }
260
+ return { created, skipped: false };
261
+ }
262
+ }
263
+
264
+ /**
265
+ * The directory of the prebuilt email worker inside the installed `@pithy-sh/email` package (holds
266
+ * wrangler.jsonc). Exported so the template test resolves the same file the deploy reads — a copy of
267
+ * this resolution in the test would be a copy free to drift from the path it is meant to guard.
268
+ */
269
+ export function emailWorkerDir(): string {
270
+ return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/email/src/workflows/worker")));
271
+ }
272
+
273
+ export interface CloudflareEmailDeprovisionerOptions {
274
+ cf: CloudflareClients;
275
+ /** The project name, from `requireProjectName` — teardown finds resources by no other key. */
276
+ project: string;
277
+ /**
278
+ * The account this teardown deletes from, and what vouches for it (#378).
279
+ *
280
+ * Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
281
+ * miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
282
+ * exits 0. A caller that has not decided which account it is tearing down cannot compile.
283
+ */
284
+ account: ConfirmedAccount;
285
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
286
+ audit?: CliAuditEmit;
287
+ }
288
+
289
+ /**
290
+ * The live {@link EmailDeprovisioner} — removes each environment's email worker and, when asked, the
291
+ * shared suppression D1, through `@pithy-sh/cloudflare`. Every step is guarded so a missing resource is a
292
+ * no-op: teardown is idempotent. The integration suite exercises the provision → teardown round trip.
293
+ */
294
+ export class CloudflareEmailDeprovisioner implements EmailDeprovisioner {
295
+ readonly #cf: CloudflareClients;
296
+ readonly #project: string;
297
+ readonly #account: ConfirmedAccount;
298
+ readonly #audit: CliAuditEmit;
299
+
300
+ constructor(options: CloudflareEmailDeprovisionerOptions) {
301
+ this.#cf = options.cf;
302
+ this.#project = options.project;
303
+ this.#account = options.account;
304
+ this.#audit = options.audit ?? (async () => {});
305
+ }
306
+
307
+ /** Delete the env's email worker if it is deployed — and refuse if "deployed" cannot be settled (#378). */
308
+ async deleteWorker(env: ManagedEnvironment): Promise<void> {
309
+ const name = emailWorkerName(this.#project, env);
310
+ if (
311
+ await findOnConfirmedAccount({
312
+ ...this.#account,
313
+ what: `the ${name} Worker`,
314
+ find: () => this.#cf.workers().getWorker(name),
315
+ })
316
+ ) {
317
+ await this.#cf.workers().deleteWorker(name);
318
+ await this.#audit({
319
+ environment: env,
320
+ action: "email/worker_removed",
321
+ outcome: "success",
322
+ severity: "warning",
323
+ resourceType: "cf_worker",
324
+ resourceId: name,
325
+ });
326
+ }
327
+ }
328
+
329
+ /** Delete this project's suppression D1 if it exists — destructive, called only on a full destroy. */
330
+ async deleteSuppressionDatabase(): Promise<void> {
331
+ const name = suppressionDatabaseName(this.#project);
332
+ const db = await this.#cf.d1Provisioner().findDatabaseByName(name);
333
+ if (db) {
334
+ await this.#cf.d1Provisioner().deleteDatabase(db.uuid);
335
+ await this.#audit({
336
+ environment: "global",
337
+ action: "email/suppression_db_removed",
338
+ outcome: "success",
339
+ severity: "warning",
340
+ resourceType: "cf_d1",
341
+ resourceId: db.uuid,
342
+ metadata: { name },
343
+ });
344
+ }
345
+ }
346
+ }