@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,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The root flags `bin.ts` answers before citty ever parses. Kept here, dependency-free, so the rule is a
6
+ * pure function with its own tests — `bin.ts` is an entry script with top-level side effects and cannot
7
+ * be imported.
8
+ */
9
+
10
+ /** Everything after this separator is payload for a child process, never a Pithy flag. */
11
+ const PASSTHROUGH = "--";
12
+
13
+ const VERSION_FLAGS = new Set(["--version", "-v"]);
14
+ const HELP_FLAGS = new Set(["--help", "-h"]);
15
+
16
+ /**
17
+ * Every spelling this module answers to, for anything that has to enumerate the CLI's flags.
18
+ *
19
+ * Exported so `docs/catalog.ts` composes the docs catalog's `globalFlags` from the sets that decide,
20
+ * rather than restating them. These four are in no command's `args` — citty answers one builtin and
21
+ * this module answers the other, both before a command is dispatched — so a walk of the command tree
22
+ * finds neither, and a check reading only that walk would call `pithy add --help` a typo.
23
+ */
24
+ export const ROOT_FLAGS: readonly string[] = [...HELP_FLAGS, ...VERSION_FLAGS];
25
+
26
+ /**
27
+ * Whether this invocation is asking for the version, on any command.
28
+ *
29
+ * citty ships a version builtin but answers it only when it is the **sole** argument
30
+ * (`rawArgs.length === 1`), so `pithy add --version` runs `add` instead — it prints `add`'s "name a
31
+ * capability" error and exits 1. docs/CLI.md §1.2 promises the flag works anywhere `--help` does, so the
32
+ * bin answers it itself.
33
+ *
34
+ * **The rule is "anywhere before `--`", matching how citty already resolves `--help`.** The alternative —
35
+ * root-level only, before the subcommand — cannot satisfy §1.2, since the flag's whole point is that it
36
+ * follows a command. What keeps that from swallowing real input is that a caller always has two escapes,
37
+ * and both are honored here: `--` for opaque payload, and the `--flag=--version` form for a value that is
38
+ * literally the string. Only a bare, exactly-matching token counts, so `--set version=2`, `--versions`,
39
+ * and a positional named `version` all pass through untouched.
40
+ *
41
+ * Help wins when both appear, because citty checks help first and this must not change that ordering.
42
+ */
43
+ export function wantsVersion(argv: string[]): boolean {
44
+ const separator = argv.indexOf(PASSTHROUGH);
45
+ const flags = separator === -1 ? argv : argv.slice(0, separator);
46
+ if (flags.some((arg) => HELP_FLAGS.has(arg))) return false;
47
+ return flags.some((arg) => VERSION_FLAGS.has(arg));
48
+ }
@@ -0,0 +1,423 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { D1Database, KVNamespace, R2Bucket } from "@cloudflare/workers-types";
7
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
8
+ import type { CloudflareKVManager } from "@pithy-sh/cloudflare/src/kv/kvManager";
9
+ import type { CloudflareImageManager } from "@pithy-sh/cloudflare/src/media/imageManager";
10
+ import type { CloudflareStreamManager } from "@pithy-sh/cloudflare/src/media/streamManager";
11
+ import { R2Credentials } from "@pithy-sh/cloudflare/src/r2/r2Credentials";
12
+ import type { CloudflareR2Manager } from "@pithy-sh/cloudflare/src/r2/r2Manager";
13
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
14
+ import { parse } from "comment-json";
15
+ import { Miniflare } from "miniflare";
16
+ import { type CloudflareAccountSelection, cloudflareCredentials, cloudflareEnv } from "../cloudflare/config";
17
+ import { wranglerConfigPath } from "../provision/featureConfig";
18
+
19
+ /**
20
+ * The seed driver resolves each backend a seed set touches to a live handle — the only thing that
21
+ * differs between a local (`dev`) run and a live (`staging`/`prod`) one, exactly as the migration
22
+ * driver does for D1. `dev` resolves D1, KV, and R2 from the same Miniflare stores `wrangler dev` uses
23
+ * under `.wrangler/state`; other environments resolve them from `@pithy-sh/cloudflare` REST managers,
24
+ * with ids read from the target env's `wrangler.jsonc` block and credentials from `cloudflareEnv`.
25
+ *
26
+ * Images and Stream have no local emulation, so they are **always** remote: the driver builds them from
27
+ * credentials regardless of `--env`, and only when a set actually has media (the clients are lazy, so a
28
+ * `dev` run with no media never needs credentials).
29
+ *
30
+ * Every remote handle is built through an injectable factory seam (mirroring migrate's `remoteD1`), so
31
+ * tests substitute in-memory or faked managers for the network clients.
32
+ */
33
+
34
+ /** Test seam: build the remote D1 for a binding instead of the default REST-backed client. */
35
+ export type RemoteD1Factory = (args: { binding: string; databaseId: string }) => D1Database;
36
+
37
+ /** Test seam: build the remote KV manager for a binding instead of the default REST-backed client. */
38
+ export type RemoteKvFactory = (args: { binding: string; namespaceId: string }) => CloudflareKVManager;
39
+
40
+ /** Test seam: build the remote R2 manager for a binding instead of the default REST-backed client. */
41
+ export type RemoteR2Factory = (args: { binding: string; bucketName: string }) => CloudflareR2Manager;
42
+
43
+ /** Test seam: build the (always-remote) Images manager instead of the default account client. */
44
+ export type ImagesFactory = () => CloudflareImageManager;
45
+
46
+ /** Test seam: build the (always-remote) Stream manager instead of the default account client. */
47
+ export type StreamFactory = () => CloudflareStreamManager;
48
+
49
+ /**
50
+ * A resolved KV target. Local runs get a live Miniflare {@link KVNamespace} (wrap it in `TypedKv`);
51
+ * remote runs get the REST {@link CloudflareKVManager}, whose surface differs from a binding — the
52
+ * caller writes through it directly. The `kind` tag lets the writer branch without a type assertion.
53
+ */
54
+ export type KvSeedTarget =
55
+ | { readonly kind: "local"; readonly namespace: KVNamespace }
56
+ | { readonly kind: "remote"; readonly manager: CloudflareKVManager };
57
+
58
+ /**
59
+ * A resolved R2 target. Local runs get a live Miniflare {@link R2Bucket}; remote runs get the REST
60
+ * {@link CloudflareR2Manager} (S3-signed uploads). The `kind` tag lets the writer branch without a cast.
61
+ */
62
+ export type R2SeedTarget =
63
+ | { readonly kind: "local"; readonly bucket: R2Bucket }
64
+ | { readonly kind: "remote"; readonly manager: CloudflareR2Manager };
65
+
66
+ /** A resolved set of backends to seed — the only thing that differs between local and remote — plus teardown. */
67
+ export interface SeedDriver {
68
+ /** The D1 for a binding (Kysely-wrappable). Local Miniflare store, or the remote REST-backed database. */
69
+ d1(binding: string): D1Database;
70
+ /** The KV target for a binding — a local namespace or the remote manager. */
71
+ kv(binding: string): KvSeedTarget;
72
+ /** The R2 target for a binding — a local bucket or the remote manager. */
73
+ r2(binding: string): R2SeedTarget;
74
+ /** The always-remote Images manager. Built from credentials on first use. */
75
+ images(): CloudflareImageManager;
76
+ /** The always-remote Stream manager. Built from credentials on first use. */
77
+ stream(): CloudflareStreamManager;
78
+ /** Release the driver's resources (the Miniflare instance locally; a no-op remotely). */
79
+ dispose(): Promise<void>;
80
+ }
81
+
82
+ /** Options for {@link openSeedDriver}. */
83
+ export interface SeedDriverOptions {
84
+ /**
85
+ * The Worker's directory — its `wrangler.jsonc` supplies the D1/KV/R2 bindings and their ids. Bindings
86
+ * are per-Worker because a Worker's resources are declared on the Worker that uses them.
87
+ */
88
+ workerDir: string;
89
+ /**
90
+ * The project root — the owner of the `.wrangler/state` stores `wrangler dev` uses, and of the one
91
+ * repo-wide `.dev.vars` the remote clients read their credentials from. Persistence is deliberately
92
+ * project-scoped, not Worker-scoped: two Workers that declare the same binding share one local store,
93
+ * which a per-Worker persistence directory would silently split in two.
94
+ */
95
+ persistRoot: string;
96
+ /** Target environment. `dev` resolves locally via Miniflare; other environments resolve over REST. */
97
+ env: string;
98
+ /**
99
+ * The Cloudflare account this project belongs to. A remote seed writes rows into a real D1 and objects
100
+ * into a real R2; the wrong account's credentials write this project's fixtures into another company's
101
+ * tenant (#206). Required (#234) — of the six declarations #226 measured, this is the one with the
102
+ * most to lose, and both of its non-test callers (`seed/run.ts`, `dashboard/registry.ts`) omitted it.
103
+ */
104
+ account: CloudflareAccountSelection | null;
105
+ /** Test seam for the remote D1 client. */
106
+ remoteD1?: RemoteD1Factory;
107
+ /** Test seam for the remote KV client. */
108
+ remoteKv?: RemoteKvFactory;
109
+ /** Test seam for the remote R2 client. */
110
+ remoteR2?: RemoteR2Factory;
111
+ /** Test seam for the (always-remote) Images client. */
112
+ images?: ImagesFactory;
113
+ /** Test seam for the (always-remote) Stream client. */
114
+ stream?: StreamFactory;
115
+ }
116
+
117
+ /**
118
+ * One D1 binding entry in wrangler.jsonc — the fields the seed driver reads to resolve a store.
119
+ * `database_name` is not among them: wrangler ignores it when binding a local D1 (see
120
+ * {@link resolveStoreIds}), so seeding must ignore it too. Leaving it off the type makes reading it a
121
+ * compile error.
122
+ */
123
+ interface D1Binding {
124
+ binding: string;
125
+ database_id?: string;
126
+ }
127
+
128
+ /** One KV namespace binding entry in wrangler.jsonc. */
129
+ interface KvBinding {
130
+ binding: string;
131
+ id?: string;
132
+ }
133
+
134
+ /** One R2 bucket binding entry in wrangler.jsonc. */
135
+ interface R2Binding {
136
+ binding: string;
137
+ bucket_name?: string;
138
+ }
139
+
140
+ /** The bindings block wrangler.jsonc carries at the top level and inside each `env`. */
141
+ interface WranglerBindings {
142
+ d1_databases?: D1Binding[];
143
+ kv_namespaces?: KvBinding[];
144
+ r2_buckets?: R2Binding[];
145
+ }
146
+
147
+ /** The wrangler.jsonc slice the seed driver reads: the local (top-level) bindings and each env's bindings. */
148
+ interface WranglerSeedConfig extends WranglerBindings {
149
+ env?: Record<string, WranglerBindings | undefined>;
150
+ }
151
+
152
+ /**
153
+ * Read and parse the config that describes this Worker in this environment. A missing file yields an
154
+ * empty config.
155
+ *
156
+ * Through {@link wranglerConfigPath}: a feature environment's ids live in the generated config under
157
+ * `.wrangler/`, never in the tracked `wrangler.jsonc` (#242), so the path is a function of the
158
+ * environment rather than a constant.
159
+ */
160
+ async function readWranglerConfig(workerDir: string, env: string): Promise<WranglerSeedConfig> {
161
+ try {
162
+ const raw = await readFile(wranglerConfigPath(workerDir, env), "utf8");
163
+ return parse(raw) as unknown as WranglerSeedConfig;
164
+ } catch {
165
+ return {};
166
+ }
167
+ }
168
+
169
+ /**
170
+ * One Worker's backends, by binding, as the **identity of the store each binding resolves to** for an
171
+ * environment — the same fallback chain the drivers themselves use (locally the id else the binding;
172
+ * remotely the target env stanza's id). It resolves ids only: no Miniflare instance, no REST client, no
173
+ * credentials.
174
+ *
175
+ * The fan-out needs this before it opens anything. Workers share a resource by declaring the same
176
+ * binding, so the same fixture composed into two Workers normally writes to one store and must run once —
177
+ * but "same binding" is not "same store": two Workers can point one binding at two different databases,
178
+ * a wiring `pithy migrate` supports and migrates separately. Deduping a seed set on its key alone would
179
+ * then skip the second store and report it as already seeded. A binding with no id resolves to its own
180
+ * name, which is exactly what the local driver persists under; remotely the driver raises the real
181
+ * missing-id error at write time.
182
+ */
183
+ export interface ResolvedStoreIds {
184
+ /** D1 binding → resolved database identity. */
185
+ d1: Map<string, string>;
186
+ /** KV binding → resolved namespace identity. */
187
+ kv: Map<string, string>;
188
+ /** R2 binding → resolved bucket identity. */
189
+ r2: Map<string, string>;
190
+ }
191
+
192
+ /** Read one Worker's `wrangler.jsonc` and resolve every D1/KV/R2 binding's store identity for `env`. */
193
+ export async function resolveStoreIds(options: { workerDir: string; env: string }): Promise<ResolvedStoreIds> {
194
+ const config = await readWranglerConfig(options.workerDir, options.env);
195
+ const bindings: WranglerBindings = options.env === "dev" ? config : (config.env?.[options.env] ?? {});
196
+
197
+ const ids: ResolvedStoreIds = { d1: new Map(), kv: new Map(), r2: new Map() };
198
+ for (const entry of bindings.d1_databases ?? []) {
199
+ // Locally a store persists under `database_id` else the binding, because that is what wrangler binds:
200
+ // `d1DatabaseEntry({ binding, database_id, preview_database_id })` computes
201
+ // `getRemoteId(preview_database_id ?? database_id) ?? binding` and never destructures `database_name`.
202
+ // `pithy add` writes `database_name` with no id, so folding the name in here would key a store the
203
+ // Worker never opens — seeded rows would vanish and migrate/seed would disagree. Remotely only a real
204
+ // `database_id` counts.
205
+ ids.d1.set(entry.binding, entry.database_id ?? entry.binding);
206
+ }
207
+ for (const entry of bindings.kv_namespaces ?? []) ids.kv.set(entry.binding, entry.id ?? entry.binding);
208
+ for (const entry of bindings.r2_buckets ?? []) ids.r2.set(entry.binding, entry.bucket_name ?? entry.binding);
209
+ return ids;
210
+ }
211
+
212
+ /** Return the cached handle for `binding`, or an actionable error naming the missing wrangler binding. */
213
+ function resolveLocal<T>(cache: Map<string, T>, binding: string, kind: string): T {
214
+ const handle = cache.get(binding);
215
+ if (!handle) {
216
+ throw new ValidationError({
217
+ message: `wrangler.jsonc declares no ${kind} binding "${binding}".`,
218
+ action: `Add the "${binding}" ${kind} binding to wrangler.jsonc. Run pithy seed again.`,
219
+ });
220
+ }
221
+ return handle;
222
+ }
223
+
224
+ /**
225
+ * The local driver: the same D1/KV/R2 stores `wrangler dev` uses, under `.wrangler/state`, via
226
+ * Miniflare. Every top-level binding is resolved up front so the driver's accessors stay synchronous
227
+ * (Miniflare's `get*` are async). Images/Stream are still remote — resolved lazily by `assets`.
228
+ */
229
+ async function openLocalDriver(options: SeedDriverOptions, assets: RemoteAssets): Promise<SeedDriver> {
230
+ const config = await readWranglerConfig(options.workerDir, options.env);
231
+ const d1Ids: Record<string, string> = {};
232
+ // `database_id` else the binding — wrangler's own local chain; see {@link resolveStoreIds} for why
233
+ // `database_name` must never join it.
234
+ for (const entry of config.d1_databases ?? []) d1Ids[entry.binding] = entry.database_id ?? entry.binding;
235
+ const kvIds: Record<string, string> = {};
236
+ for (const entry of config.kv_namespaces ?? []) kvIds[entry.binding] = entry.id ?? entry.binding;
237
+ const r2Ids: Record<string, string> = {};
238
+ for (const entry of config.r2_buckets ?? []) r2Ids[entry.binding] = entry.bucket_name ?? entry.binding;
239
+
240
+ const state = join(options.persistRoot, ".wrangler", "state", "v3");
241
+ const miniflare = new Miniflare({
242
+ modules: true,
243
+ script: "export default {};",
244
+ d1Databases: d1Ids,
245
+ kvNamespaces: kvIds,
246
+ r2Buckets: r2Ids,
247
+ d1Persist: join(state, "d1"),
248
+ kvPersist: join(state, "kv"),
249
+ r2Persist: join(state, "r2"),
250
+ });
251
+
252
+ const d1Cache = new Map<string, D1Database>();
253
+ for (const binding of Object.keys(d1Ids)) {
254
+ d1Cache.set(binding, (await miniflare.getD1Database(binding)) as unknown as D1Database);
255
+ }
256
+ const kvCache = new Map<string, KVNamespace>();
257
+ for (const binding of Object.keys(kvIds)) {
258
+ kvCache.set(binding, (await miniflare.getKVNamespace(binding)) as unknown as KVNamespace);
259
+ }
260
+ const r2Cache = new Map<string, R2Bucket>();
261
+ for (const binding of Object.keys(r2Ids)) {
262
+ r2Cache.set(binding, (await miniflare.getR2Bucket(binding)) as unknown as R2Bucket);
263
+ }
264
+
265
+ return {
266
+ d1: (binding) => resolveLocal(d1Cache, binding, "D1"),
267
+ kv: (binding) => ({ kind: "local", namespace: resolveLocal(kvCache, binding, "KV") }),
268
+ r2: (binding) => ({ kind: "local", bucket: resolveLocal(r2Cache, binding, "R2") }),
269
+ images: assets.images,
270
+ stream: assets.stream,
271
+ dispose: () => miniflare.dispose(),
272
+ };
273
+ }
274
+
275
+ /** Find one binding's id in a wrangler bindings block, or an actionable error naming the env and binding. */
276
+ function remoteId(
277
+ entries: { binding: string }[] | undefined,
278
+ binding: string,
279
+ read: (entry: { binding: string }) => string | undefined,
280
+ env: string,
281
+ kind: string,
282
+ idField: string,
283
+ ): string {
284
+ const entry = entries?.find((candidate) => candidate.binding === binding);
285
+ const value = entry ? read(entry) : undefined;
286
+ if (!value) {
287
+ throw new ValidationError({
288
+ message: `wrangler.jsonc env.${env} has no ${idField} for the "${binding}" ${kind} binding.`,
289
+ action: `Provision the ${env} ${kind} and set its ${idField} on ${binding}. Run pithy seed --env ${env} again.`,
290
+ });
291
+ }
292
+ return value;
293
+ }
294
+
295
+ /**
296
+ * The remote driver: each backend resolved over REST through `@pithy-sh/cloudflare`. Ids come from the
297
+ * target env's `wrangler.jsonc` block; the REST clients are the only thing that differs from local.
298
+ * Every handle is built through a factory seam (the default wraps the shared, credential-lazy clients),
299
+ * so tests substitute in-memory managers without touching the network or credentials.
300
+ */
301
+ function openRemoteDriver(options: SeedDriverOptions, clients: LazyClients, assets: RemoteAssets): SeedDriver {
302
+ const config = clients.config;
303
+ const stanza = (): WranglerBindings => config.env?.[options.env] ?? {};
304
+
305
+ const d1: RemoteD1Factory =
306
+ options.remoteD1 ?? (({ databaseId }) => clients.get().d1(databaseId) as unknown as D1Database);
307
+ const kv: RemoteKvFactory = options.remoteKv ?? (({ namespaceId }) => clients.get().kv(namespaceId));
308
+ const r2: RemoteR2Factory =
309
+ options.remoteR2 ?? (({ bucketName }) => clients.get().r2({ bucketName, ...clients.r2Credentials() }));
310
+
311
+ return {
312
+ d1: (binding) => {
313
+ const databaseId = remoteId(
314
+ stanza().d1_databases,
315
+ binding,
316
+ (e) => (e as D1Binding).database_id,
317
+ options.env,
318
+ "D1",
319
+ "database_id",
320
+ );
321
+ return d1({ binding, databaseId });
322
+ },
323
+ kv: (binding) => {
324
+ const namespaceId = remoteId(
325
+ stanza().kv_namespaces,
326
+ binding,
327
+ (e) => (e as KvBinding).id,
328
+ options.env,
329
+ "KV",
330
+ "id",
331
+ );
332
+ return { kind: "remote", manager: kv({ binding, namespaceId }) };
333
+ },
334
+ r2: (binding) => {
335
+ const bucketName = remoteId(
336
+ stanza().r2_buckets,
337
+ binding,
338
+ (e) => (e as R2Binding).bucket_name,
339
+ options.env,
340
+ "R2",
341
+ "bucket_name",
342
+ );
343
+ return { kind: "remote", manager: r2({ binding, bucketName }) };
344
+ },
345
+ images: assets.images,
346
+ stream: assets.stream,
347
+ dispose: async () => {},
348
+ };
349
+ }
350
+
351
+ /** A lazily-built, credential-checked {@link CloudflareClients} plus the parsed wrangler config it shares. */
352
+ interface LazyClients {
353
+ /** The parsed wrangler.jsonc (read once). */
354
+ config: WranglerSeedConfig;
355
+ /** Build (or return the memoized) clients, failing with an actionable error if credentials are missing. */
356
+ get(): CloudflareClients;
357
+ /** Parse the R2 S3 credential pair from the environment, failing clearly if absent or malformed. */
358
+ r2Credentials(): R2Credentials;
359
+ }
360
+
361
+ /** Build the credential-lazy clients accessor shared by the remote resources and the always-remote assets. */
362
+ function lazyClients(config: WranglerSeedConfig, account: CloudflareAccountSelection | null): LazyClients {
363
+ let clients: CloudflareClients | undefined;
364
+ let vars: Record<string, string> | undefined;
365
+ const env = (): Record<string, string> => {
366
+ vars ??= cloudflareEnv({ account });
367
+ return vars;
368
+ };
369
+ return {
370
+ config,
371
+ get() {
372
+ if (clients) return clients;
373
+ // Both refusals — the account mismatch and the empty pair — belong to `cloudflareCredentials`, so
374
+ // this driver and anything that settles the account ahead of a fan-out say them the same way (#236).
375
+ clients = new CloudflareClients(cloudflareCredentials({ account }));
376
+ return clients;
377
+ },
378
+ r2Credentials() {
379
+ const raw = env().R2_CREDENTIALS;
380
+ if (!raw) {
381
+ throw new ValidationError({
382
+ message: "R2 credentials are missing.",
383
+ action: "Set R2_CREDENTIALS (the S3 access-key/secret-key pair) to seed R2 objects.",
384
+ });
385
+ }
386
+ try {
387
+ return R2Credentials.parse(JSON.parse(raw));
388
+ } catch {
389
+ throw new ValidationError({
390
+ message: "R2_CREDENTIALS is not a valid access-key/secret-key pair.",
391
+ action: "Set R2_CREDENTIALS to a JSON object with accessKeyId and secretAccessKey.",
392
+ });
393
+ }
394
+ },
395
+ };
396
+ }
397
+
398
+ /** The always-remote asset accessors — Images and Stream, built from credentials (or a test seam). */
399
+ interface RemoteAssets {
400
+ images: () => CloudflareImageManager;
401
+ stream: () => CloudflareStreamManager;
402
+ }
403
+
404
+ /** Build the always-remote Images/Stream accessors: a test seam if given, else the credential-lazy client. */
405
+ function remoteAssets(options: SeedDriverOptions, clients: LazyClients): RemoteAssets {
406
+ return {
407
+ images: options.images ?? (() => clients.get().images()),
408
+ stream: options.stream ?? (() => clients.get().stream()),
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Open the seed driver for a project and environment — the backend-resolution seam behind `pithy seed`.
414
+ * `dev` resolves D1/KV/R2 from local Miniflare stores; other environments resolve them over the REST
415
+ * managers keyed by the env's `wrangler.jsonc` ids. Images and Stream are always remote, built lazily
416
+ * from credentials so a local run with no media never needs them.
417
+ */
418
+ export async function openSeedDriver(options: SeedDriverOptions): Promise<SeedDriver> {
419
+ const config = await readWranglerConfig(options.workerDir, options.env);
420
+ const clients = lazyClients(config, options.account);
421
+ const assets = remoteAssets(options, clients);
422
+ return options.env === "dev" ? openLocalDriver(options, assets) : openRemoteDriver(options, clients, assets);
423
+ }
@@ -0,0 +1,187 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { ConflictError, InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { buildAssetMetadata } from "@pithy-sh/core/src/seed/metadata";
8
+ import type { MediaSeedItem } from "@pithy-sh/core/src/seed/seed";
9
+ import { z } from "zod";
10
+ import { writeFileAtomic } from "../project/atomic";
11
+ import { readOptionalFile } from "../project/readOptionalFile";
12
+
13
+ /**
14
+ * The filesystem side of media seeding — the only part of `pithy seed` that reads fixture bytes and
15
+ * writes a minted UUID back. Core defines the {@link MediaSeedItem} type; this module reads the item's
16
+ * bytes and its `ref` sidecar, uploads through an injected {@link MediaUploader}, and (for a `once`
17
+ * asset) records the minted id atomically so later runs skip the upload.
18
+ *
19
+ * Both the filesystem and the uploader are injectable seams so the orchestrator's tests drive the
20
+ * once/always/idempotent logic against a temp sidecar and a fake minter, with no live account.
21
+ */
22
+
23
+ /** The `ref` sidecar's shape: the minted asset UUID, recorded after a `once` upload. */
24
+ const MediaRef = z
25
+ .object({
26
+ /** The minted Images id or Stream uid, recorded so a later run references it instead of re-uploading. */
27
+ id: z.string().describe("The minted asset UUID (Images id or Stream uid) recorded by the first run."),
28
+ })
29
+ .partial()
30
+ .describe("The JSON sidecar a `once` media seed writes to record its minted asset UUID.");
31
+ type MediaRef = z.output<typeof MediaRef>;
32
+
33
+ /**
34
+ * The byte uploads media seeding needs, one per shared store. Injectable so tests substitute a fake
35
+ * minter; the default (built by the orchestrator from the driver's managers) wraps the
36
+ * `@pithy-sh/cloudflare` seed helpers. Each returns the minted id to record — Stream's `uid` is
37
+ * normalized to `id` so the sidecar shape is one field for both stores.
38
+ */
39
+ export interface MediaUploader {
40
+ /** Upload image bytes with the given metadata; resolve the minted Images id. */
41
+ images(bytes: Uint8Array, metadata: Record<string, string>): Promise<{ id: string }>;
42
+ /** Upload video bytes with the given metadata; resolve the minted Stream uid (as `id`). */
43
+ stream(bytes: Uint8Array, metadata: Record<string, string>): Promise<{ id: string }>;
44
+ }
45
+
46
+ /**
47
+ * The filesystem operations media seeding performs. Injectable so tests supply an in-memory fake and
48
+ * assert the write-back without touching disk; the default {@link nodeMediaFs} uses `node:fs`.
49
+ */
50
+ export interface MediaFs {
51
+ /** Read a fixture's bytes. */
52
+ readBytes(path: string): Promise<Uint8Array>;
53
+ /** Read the sidecar's text, or `null` when it does not exist yet (a never-uploaded `once` asset). */
54
+ readText(path: string): Promise<string | null>;
55
+ /** Write the sidecar's text atomically, so a crash never leaves a torn file. */
56
+ writeTextAtomic(path: string, text: string): Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * The default {@link MediaFs}: `node:fs`, with the sidecar written through {@link writeFileAtomic}.
61
+ *
62
+ * It used to roll its own temp-file-plus-rename — a UUID name, a plain `writeFile`, a `rename` — and so
63
+ * got none of what the primitive does: no exclusive create, so anything already at the temp path was
64
+ * written through rather than refused; no ownership check on the links it followed, so a planted symlink
65
+ * decided where the write landed; no mode; and no sweep of the file a killed run leaves behind. The
66
+ * payload here is an asset-id sidecar rather than a credential, which changes what it costs, not whether
67
+ * it is the same shape. One writer, one place to get it wrong.
68
+ */
69
+ export const nodeMediaFs: MediaFs = {
70
+ readBytes: (path) => readFile(path),
71
+ readText: (path) =>
72
+ // A missing sidecar is the expected first-run state — not an error. Anything else refuses, and which
73
+ // errno is which is {@link readOptionalFile}'s decision rather than this module's: a sidecar that is
74
+ // there and will not open reading as "never uploaded" means `writeTextAtomic` renames a fresh id over
75
+ // the one recorded in it. It used to rethrow node's own error, which reached an adopter as a bare
76
+ // errno and a stack from the middle of a seed run (#203); the sentence below is what it says now.
77
+ readOptionalFile(path, {
78
+ unreadable: ({ code, cause }) =>
79
+ new ConflictError(
80
+ {
81
+ message: `Can't read the media sidecar ${path}.`,
82
+ action: "Fix the file's permissions, or move it aside, and run pithy seed again.",
83
+ detail: `${code ?? "unknown error"} while reading ${path}`,
84
+ },
85
+ { cause },
86
+ ),
87
+ }),
88
+ writeTextAtomic: (path, text) => writeFileAtomic(path, text),
89
+ };
90
+
91
+ /** What seeding one media item did this run — the same shape a dry-run plan reports per asset. */
92
+ export interface SeedMediaResult {
93
+ /** The shared asset store the item targeted. */
94
+ store: MediaSeedItem["store"];
95
+ /** The item's upload policy. */
96
+ mode: MediaSeedItem["mode"];
97
+ /** What this run did: `upload` (first `once`), `skip` (recorded `once`), or `reupload` (`always`). */
98
+ action: "upload" | "skip" | "reupload";
99
+ /** The minted (or recorded) asset UUID, when there is one — absent only on a dry-run first `once`. */
100
+ id?: string;
101
+ }
102
+
103
+ /** Options for {@link seedMediaItem}. */
104
+ export interface SeedMediaOptions {
105
+ /** The environment being seeded — stamped into the standard asset-metadata block on upload. */
106
+ env: string;
107
+ /**
108
+ * The directory `file`/`ref` are resolved against when they are not absolute — the set's own
109
+ * `baseDir` (the seed module directory), or the project root when the set declares none.
110
+ */
111
+ baseDir: string;
112
+ /** The byte uploader. Required for a real run; omitted for a dry-run (which never uploads). */
113
+ uploader?: MediaUploader;
114
+ /** The filesystem seam. Defaults to {@link nodeMediaFs}. */
115
+ fs?: MediaFs;
116
+ /** Plan only: resolve the action from the sidecar without reading bytes, uploading, or writing back. */
117
+ dryRun?: boolean;
118
+ }
119
+
120
+ /** Resolve a fixture path: absolute paths pass through, relative paths join the base directory. */
121
+ function resolvePath(baseDir: string, path: string): string {
122
+ return isAbsolute(path) ? path : join(baseDir, path);
123
+ }
124
+
125
+ /** Read the recorded UUID from a sidecar, or `undefined` when none is recorded (or the file is absent). */
126
+ async function readRecordedId(fs: MediaFs, refPath: string): Promise<string | undefined> {
127
+ const text = await fs.readText(refPath);
128
+ if (text === null || text.trim() === "") return undefined;
129
+ let parsed: MediaRef;
130
+ try {
131
+ parsed = MediaRef.parse(JSON.parse(text));
132
+ } catch {
133
+ throw new ValidationError({
134
+ message: `The media sidecar "${refPath}" is not a valid ref file.`,
135
+ action: 'Delete the sidecar to re-mint the asset, or restore it to a { "id": "…" } JSON object.',
136
+ });
137
+ }
138
+ return parsed.id;
139
+ }
140
+
141
+ /** Upload the item's bytes through the store's uploader, or fail loudly if no uploader was provided. */
142
+ async function upload(
143
+ item: MediaSeedItem,
144
+ bytes: Uint8Array,
145
+ metadata: Record<string, string>,
146
+ uploader?: MediaUploader,
147
+ ): Promise<string> {
148
+ if (!uploader) {
149
+ throw new InternalError({ detail: `Media upload attempted for "${item.file}" without a configured uploader.` });
150
+ }
151
+ const result =
152
+ item.store === "images" ? await uploader.images(bytes, metadata) : await uploader.stream(bytes, metadata);
153
+ return result.id;
154
+ }
155
+
156
+ /**
157
+ * Seed one media item into the shared Images/Stream store, idempotently.
158
+ *
159
+ * `once` (the default) uploads only the first time: if the `ref` sidecar already records a UUID the
160
+ * upload is skipped and the recorded id is returned; otherwise the bytes are uploaded with the
161
+ * standard asset-metadata block (built by the caller) and the minted UUID is written back to the
162
+ * sidecar **atomically**, so a later run skips it. `always` re-uploads every run, scoped by metadata,
163
+ * and never writes a UUID back. A `dryRun` resolves the action from the sidecar alone — it reads no
164
+ * bytes, uploads nothing, and writes nothing.
165
+ */
166
+ export async function seedMediaItem(item: MediaSeedItem, options: SeedMediaOptions): Promise<SeedMediaResult> {
167
+ const fs = options.fs ?? nodeMediaFs;
168
+ const refPath = resolvePath(options.baseDir, item.ref);
169
+ const filePath = resolvePath(options.baseDir, item.file);
170
+
171
+ if (item.mode === "once") {
172
+ const recorded = await readRecordedId(fs, refPath);
173
+ if (recorded !== undefined) return { store: item.store, mode: item.mode, action: "skip", id: recorded };
174
+ if (options.dryRun) return { store: item.store, mode: item.mode, action: "upload" };
175
+
176
+ const metadata = buildAssetMetadata(options.env, item.metadata);
177
+ const id = await upload(item, await fs.readBytes(filePath), metadata, options.uploader);
178
+ await fs.writeTextAtomic(refPath, `${JSON.stringify({ id }, null, 2)}\n`);
179
+ return { store: item.store, mode: item.mode, action: "upload", id };
180
+ }
181
+
182
+ // `always`: re-upload every run, scoped by metadata, and never record a UUID.
183
+ if (options.dryRun) return { store: item.store, mode: item.mode, action: "reupload" };
184
+ const metadata = buildAssetMetadata(options.env, item.metadata);
185
+ const id = await upload(item, await fs.readBytes(filePath), metadata, options.uploader);
186
+ return { store: item.store, mode: item.mode, action: "reupload", id };
187
+ }