@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,229 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import { defineCommand } from "citty";
7
+ import { type CliAuditEmit, createRemoteCliAudit } from "../audit/cliAudit";
8
+ import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
9
+ import { renderDevSecretsNotes } from "../devSecrets/report";
10
+ import { type DevSecretsSeedReport, seedProjectDevSecrets } from "../devSecrets/seed";
11
+ import { type ResetPreviewEntry, resolveWorkerScopes } from "../migrations/run";
12
+ import { loadProject, loadProjectCloudflare, requireProjectName } from "../project/config";
13
+ import { ENV_ARG, requireEnvironment } from "../project/environment";
14
+ import { type SeedRunReport, type SeedWorkerReport, seedProject } from "../seed/run";
15
+ import { PRODUCTION_CONFIRM_PHRASE, resetConfirmPhrase } from "../seed/safety";
16
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
17
+ import { saffron } from "../terminal/style";
18
+
19
+ /** One line per set: what it wrote, per backend (docs/CLI.md §3). Empty backends are omitted. */
20
+ function describeSet(set: {
21
+ name: string;
22
+ d1: { rows: number }[];
23
+ kv: { entries: number }[];
24
+ r2: unknown[];
25
+ media: unknown[];
26
+ }): string {
27
+ const rows = set.d1.reduce((sum, entry) => sum + entry.rows, 0);
28
+ const entries = set.kv.reduce((sum, entry) => sum + entry.entries, 0);
29
+ const parts: string[] = [];
30
+ if (rows > 0) parts.push(`${rows} row${rows === 1 ? "" : "s"}`);
31
+ if (entries > 0) parts.push(`${entries} entr${entries === 1 ? "y" : "ies"}`);
32
+ if (set.r2.length > 0) parts.push(`${set.r2.length} object${set.r2.length === 1 ? "" : "s"}`);
33
+ if (set.media.length > 0) parts.push(`${set.media.length} asset${set.media.length === 1 ? "" : "s"}`);
34
+ return `${set.name}: ${parts.length > 0 ? parts.join(", ") : "nothing to seed"}.`;
35
+ }
36
+
37
+ /** One line per reset database: schema dropped and recreated, or (dry run) what would be (docs/CLI.md §3). */
38
+ function describeReset(entry: ResetPreviewEntry, dryRun: boolean): string {
39
+ const migrations = `${entry.migrations} migration${entry.migrations === 1 ? "" : "s"}`;
40
+ return dryRun
41
+ ? `Would reset ${entry.database} (${entry.binding}): ${migrations}.`
42
+ : `Reset ${entry.database} (${entry.binding}): ${migrations} rolled back and reapplied.`;
43
+ }
44
+
45
+ /**
46
+ * One worker's block of the run report: its sets, then what it skipped and why. A worker is named on
47
+ * every line so a fan-out over several workers reads as one list, not several interleaved ones.
48
+ */
49
+ function describeWorker(worker: SeedWorkerReport, env: string, width: number): string[] {
50
+ const name = worker.worker.padEnd(width);
51
+ const lines: string[] = [];
52
+ for (const key of worker.skippedByEnv) lines.push(`${name} skipped ${key}: not allowed in ${env}.`);
53
+ for (const key of worker.shared) lines.push(`${name} ${key}: already seeded by another worker.`);
54
+ for (const set of worker.sets) lines.push(`${name} ${describeSet(set)}`);
55
+ return lines;
56
+ }
57
+
58
+ /**
59
+ * The whole human report for one seed run, as a pure function of its result — the peer of
60
+ * `renderDoctorText`, and for the same reason: `docs/CLI.md` §8.2 and §8.5 paste these transcripts, so
61
+ * they have to be a value a test can render and compare against the document (`seedDocs.test.ts`).
62
+ * Assembling them across inline `process.stdout.write` calls made that impossible, and the blocks
63
+ * rotted. The command writes what this returns, plus the trailing newline.
64
+ *
65
+ * The order is the run's own: what the reset destroyed, then what each Worker wrote, then the dry-run
66
+ * reminder, then `Done.` The `DESTRUCTIVE.` banner leads a real reset only — a preview dropped nothing.
67
+ */
68
+ export function renderSeedText(report: SeedRunReport): string {
69
+ const reset = report.reset ?? [];
70
+ const lines: string[] = [];
71
+
72
+ if (reset.length > 0 && !report.dryRun) {
73
+ lines.push(`DESTRUCTIVE${saffron(".")} Every table in ${report.env} was dropped and recreated.`);
74
+ }
75
+ for (const entry of reset) lines.push(describeReset(entry, report.dryRun));
76
+
77
+ if (report.workers.every((worker) => worker.sets.length === 0)) {
78
+ lines.push(`Nothing to seed for ${report.env}.`);
79
+ }
80
+ // One column width across the whole fan-out, so every Worker's lines align into one list.
81
+ const width = Math.max(0, ...report.workers.map((worker) => worker.worker.length));
82
+ for (const worker of report.workers) lines.push(...describeWorker(worker, report.env, width));
83
+
84
+ if (report.dryRun) lines.push("Dry run. Nothing written.");
85
+ lines.push(formatDone());
86
+ return lines.join("\n");
87
+ }
88
+
89
+ /**
90
+ * The audit emitter for a seed run, or a no-op when auditing is unavailable. A `--redo` schema reset is
91
+ * the most destructive thing the seeder does, so it must leave a record of who reset which environment.
92
+ */
93
+ async function buildSeedAudit(
94
+ projectDir: string,
95
+ env: string,
96
+ capabilities: readonly Capability[],
97
+ account: CloudflareAccountSelection | null,
98
+ ): Promise<CliAuditEmit> {
99
+ const vars = cloudflareEnv({ account });
100
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
101
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
102
+ if (!accountId || !apiToken) return async () => {};
103
+ // Data-plane: a `dev` seed or reset only touches local Miniflare, so it is not audited.
104
+ return createRemoteCliAudit({
105
+ projectDir,
106
+ env,
107
+ capabilities,
108
+ clients: new CloudflareClients({ accountId, apiToken }),
109
+ apiToken,
110
+ });
111
+ }
112
+
113
+ /** The interactive reset-confirm prompt. Names the destruction plainly before asking for the phrase. */
114
+ function resetPrompt(env: string): () => Promise<string> {
115
+ return async () => {
116
+ const { isCancel, text } = await import("@clack/prompts");
117
+ const answer = await text({
118
+ message: `DESTRUCTIVE: this drops every table in ${env} and all data is lost. Type "${resetConfirmPhrase(env)}" to confirm:`,
119
+ });
120
+ return isCancel(answer) ? "" : answer;
121
+ };
122
+ }
123
+
124
+ /** The interactive production-confirm prompt: an `@clack/prompts` text field asking for the exact phrase. */
125
+ function productionPrompt(): () => Promise<string> {
126
+ return async () => {
127
+ const { isCancel, text } = await import("@clack/prompts");
128
+ const answer = await text({
129
+ message: `This writes to production. Type "${PRODUCTION_CONFIRM_PHRASE}" to confirm:`,
130
+ });
131
+ return isCancel(answer) ? "" : answer;
132
+ };
133
+ }
134
+
135
+ export default defineCommand({
136
+ meta: { name: "seed", description: "Seed an environment from your Zod-typed fixtures" },
137
+ args: {
138
+ env: ENV_ARG,
139
+ worker: { type: "string", description: "Seed one worker instead of every worker in apps/" },
140
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
141
+ "dry-run": { type: "boolean", default: false, description: "Print the write plan; change nothing" },
142
+ redo: {
143
+ type: "boolean",
144
+ default: false,
145
+ description: "DESTRUCTIVE: drop every table and recreate the schema before seeding. All data is lost",
146
+ },
147
+ "confirm-reset": {
148
+ type: "string",
149
+ description: 'Unlock a non-dev reset non-interactively: "yes, i really want to reset <env>"',
150
+ },
151
+ yes: { type: "boolean", default: false, description: "Confirm a non-dev environment" },
152
+ "confirm-production": {
153
+ type: "string",
154
+ description: `Unlock production non-interactively: "${PRODUCTION_CONFIRM_PHRASE}"`,
155
+ },
156
+ },
157
+ run: ({ args }) =>
158
+ withErrorReporting(args.json, async () => {
159
+ const env = requireEnvironment(args.env);
160
+ const projectDir = process.cwd();
161
+ const config = await loadProject(projectDir);
162
+ // The account this project belongs to, resolved from the config just loaded. Used by the seed run
163
+ // itself and by the audit emitter — one answer, so the two can never disagree about whose D1 was
164
+ // written and whose trail records it (#234).
165
+ const account = loadProjectCloudflare(config) ?? null;
166
+ const dryRun = args["dry-run"];
167
+ const interactive = !args.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
168
+
169
+ // Resolved once: the fan-out seeds these workers, and the audit emitter needs their capabilities
170
+ // to know whether the project composes `audit` at all.
171
+ const workers = await resolveWorkerScopes({
172
+ projectDir,
173
+ ...(args.worker !== undefined ? { worker: args.worker } : {}),
174
+ });
175
+
176
+ // Dev secrets first, and only for `dev`. The dev secrets file is machine-local — there is no staging
177
+ // copy of it, and a deployed environment's secrets come from `pithy secrets create` through the
178
+ // manager Workflow. Before the fixtures, because a fixture that signs a token or a link needs the
179
+ // key that signs it to already be in the store.
180
+ //
181
+ // Never fatal to a `--dry-run`, and never run by one: a dry run writes nothing, and seeding a
182
+ // secret is a write.
183
+ const devSecrets =
184
+ env === "dev" && !dryRun ? await seedProjectDevSecrets({ projectDir }) : (null as DevSecretsSeedReport | null);
185
+ if (devSecrets && !args.json) {
186
+ for (const line of renderDevSecretsNotes(devSecrets)) process.stdout.write(`${line}\n`);
187
+ }
188
+ // A `.dev.vars` pithy did not generate is never overwritten and never merged (#154) — so a Worker
189
+ // that was supposed to get one has not got one, and that is a failed run. The lines above already
190
+ // name the file and point at `.dev.vars.local`; this is what makes a script notice. Not a throw:
191
+ // the fixtures below are the rest of the run and they are worth doing.
192
+ if (devSecrets && (devSecrets.devVarsRefused ?? []).length > 0) process.exitCode = 1;
193
+
194
+ const report = await seedProject({
195
+ projectDir,
196
+ // `requireProjectName`, never `resolveProjectName`: a fixture can mint Images/Stream assets, and
197
+ // this name is the owner stamped into their metadata — the only handle a later sweep has on them.
198
+ project: requireProjectName(config),
199
+ // The account this project names, or `null` when it names none. Not the default credentials file:
200
+ // a non-`dev` seed writes rows into a real D1 and objects into a real R2 (#234).
201
+ account,
202
+ workers,
203
+ env,
204
+ includeExamples: config.seed?.includeExamples ?? false,
205
+ dryRun,
206
+ redo: args.redo,
207
+ yes: args.yes,
208
+ json: args.json,
209
+ confirmProduction: args["confirm-production"],
210
+ confirmReset: args["confirm-reset"],
211
+ productionEnvironments: config.seed?.productionEnvironments,
212
+ prompt: interactive ? productionPrompt() : undefined,
213
+ promptReset: interactive ? resetPrompt(env) : undefined,
214
+ audit: await buildSeedAudit(
215
+ projectDir,
216
+ env,
217
+ workers.flatMap((worker) => worker.capabilities),
218
+ account,
219
+ ),
220
+ });
221
+
222
+ if (args.json) {
223
+ process.stdout.write(`${formatJsonLine({ ...report, devSecrets })}\n`);
224
+ return;
225
+ }
226
+
227
+ process.stdout.write(`${renderSeedText(report)}\n`);
228
+ }),
229
+ });
@@ -0,0 +1,309 @@
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 { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
7
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { managerWorkerName } from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
9
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
10
+ import { defineCommand } from "citty";
11
+ import { parse } from "comment-json";
12
+ import { createProjectCliAudit } from "../audit/cliAudit";
13
+ import { resolveR2Credentials } from "../capabilities/r2Bucket";
14
+ import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
15
+ import {
16
+ CloudflareStorageDeprovisioner,
17
+ CloudflareStorageProvisioner,
18
+ loadStorage,
19
+ type StorageEnvResources,
20
+ } from "../capabilities/storageProvisioner";
21
+ import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
22
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
23
+ import { applyAppBindings, appWorkflowBindings } from "../project/appBindings";
24
+ import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "../project/config";
25
+ import { projectCapabilities, resolveWorkers } from "../project/workerScope";
26
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
27
+
28
+ /**
29
+ * `pithy storage provision` / `deprovision`.
30
+ *
31
+ * `pithy add storage` writes bindings and touches no Cloudflare account. This command stands up what
32
+ * those bindings point at: the per-environment R2 bucket, the `storage-r2-credentials` secret, and the
33
+ * prebuilt sweep worker that hosts the daily orphan reconciliation.
34
+ *
35
+ * **The R2 key pair is supplied, not minted.** Cloudflare exposes no API for creating an R2 S3
36
+ * access-key pair, so it comes from flags or `R2_CREDENTIALS` in `.dev.vars` and is written into the
37
+ * secret as given. Make the pair under R2 → Manage API tokens.
38
+ */
39
+
40
+ /**
41
+ * The audit emitter for a storage command. Provisioning spans every managed environment at once, so
42
+ * there is no single target env to key the audit database on — `"dev"` is the fallback (mirrors
43
+ * `pithy media`'s convention for env-spanning commands). A no-op when creds or the audit capability
44
+ * aren't there.
45
+ */
46
+ async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
47
+ // `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
48
+ // single value is true for the run; each event states the environment it acted on.
49
+ return createProjectCliAudit({ projectDir, accountId, apiToken });
50
+ }
51
+
52
+ /** Load the storage capability's resolved config from `pithy.config.ts`. */
53
+ async function loadStorageConfig(projectDir: string) {
54
+ const { isStorageCapability } = await loadStorage();
55
+ // Capabilities live in each Worker's `apps/<name>/pithy.config.ts`; provisioning is one
56
+ // project-wide decision, so the first Worker composing this capability provides it.
57
+ const capability = (await resolveWorkers({ projectDir }).then(projectCapabilities)).find(isStorageCapability);
58
+ if (!capability) {
59
+ throw new ValidationError({
60
+ message: "The storage capability is not configured.",
61
+ action: "Add `storage({ ... })` to pithy.config.ts (run `pithy add storage`).",
62
+ });
63
+ }
64
+ return capability.storageConfig;
65
+ }
66
+
67
+ /**
68
+ * The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
69
+ *
70
+ * The account is a parameter rather than an ambient, so this cannot resolve before something has
71
+ * established which account the project is for (#206).
72
+ *
73
+ * It also carries **what vouches for the account** (#378). A bare id is what every destructive and
74
+ * creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
75
+ * "I asked an account nothing claims" — the two arrive as one empty listing.
76
+ */
77
+ function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
78
+ account: ConfirmedAccount;
79
+ accountId: string;
80
+ apiToken: string;
81
+ storeId: string;
82
+ r2Raw: string | undefined;
83
+ } {
84
+ const vars = cloudflareEnv({ account });
85
+ const confirmation = cloudflareAccountConfirmation({ account });
86
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
87
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
88
+ const storeId = vars.SECRETS_STORE_ID ?? "";
89
+ if (!accountId || !apiToken) {
90
+ throw new ValidationError({
91
+ message: "Cloudflare credentials are missing.",
92
+ action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
93
+ });
94
+ }
95
+ if (!storeId) {
96
+ throw new ValidationError({
97
+ message: "The CF Secrets Store id is missing.",
98
+ action: "Run pithy add secrets to record SECRETS_STORE_ID (the sweep worker decrypts its credentials from it).",
99
+ });
100
+ }
101
+ return { account: { accountId, confirmation }, accountId, apiToken, storeId, r2Raw: vars.R2_CREDENTIALS };
102
+ }
103
+
104
+ /** A wrangler env stanza — only the fields the sweep worker deploy reads from the project's config. */
105
+ interface WranglerStanza {
106
+ d1_databases?: { binding: string; database_id?: string }[];
107
+ env?: Record<string, WranglerStanza | undefined>;
108
+ }
109
+
110
+ /**
111
+ * Resolve the per-environment resources the sweep worker binds, from the project's `wrangler.jsonc`
112
+ * (the app `DB` id per env) and a live lookup of the env's secrets database. Each missing value throws
113
+ * an actionable error rather than deploying a half-wired worker.
114
+ */
115
+ function buildResolveEnv(
116
+ projectDir: string,
117
+ cf: CloudflareClients,
118
+ /**
119
+ * The project name the secrets database is found by — `<project>-<env>-secrets`. Resolved once by the
120
+ * caller via `requireProjectName`, never guessed: the lookup is by name, so a wrong one either reports
121
+ * a database that "does not exist" or binds another project's secrets store.
122
+ */
123
+ project: string,
124
+ /**
125
+ * The account the secrets database is looked for on, and what vouches for it (#378).
126
+ *
127
+ * The refusal below reads a missing database as "provision it first". Against an account nothing
128
+ * claims, that database is missing because this run asked the wrong account — and the sentence sends
129
+ * an operator to run a provisioning command they have already run.
130
+ */
131
+ account: ConfirmedAccount,
132
+ ): (env: ManagedEnvironment) => Promise<StorageEnvResources> {
133
+ return async (env) => {
134
+ const config = parse(await readFile(join(projectDir, "wrangler.jsonc"), "utf8")) as unknown as WranglerStanza;
135
+ const stanza = config.env?.[env];
136
+ if (!stanza) {
137
+ throw new ValidationError({
138
+ message: `wrangler.jsonc has no env.${env} stanza.`,
139
+ action: `Add the ${env} environment to wrangler.jsonc with its DB binding.`,
140
+ });
141
+ }
142
+ const appDatabaseId = stanza.d1_databases?.find((db) => db.binding === "DB")?.database_id;
143
+ if (!appDatabaseId) {
144
+ throw new ValidationError({
145
+ message: `wrangler.jsonc env.${env} has no DB database_id.`,
146
+ action: `Provision the ${env} app database and set its id on the DB binding.`,
147
+ });
148
+ }
149
+ const secretsDb = await findOnConfirmedAccount({
150
+ ...account,
151
+ what: `the ${managerWorkerName(project, env)} database`,
152
+ find: () => cf.d1Provisioner().findDatabaseByName(managerWorkerName(project, env)),
153
+ });
154
+ if (!secretsDb) {
155
+ throw new ValidationError({
156
+ message: `The ${env} secrets database (${managerWorkerName(project, env)}) does not exist.`,
157
+ action: "Run `pithy secrets provision` first — the sweep worker reads its credentials from it.",
158
+ });
159
+ }
160
+ return { appDatabaseId, secretsDatabaseId: secretsDb.uuid };
161
+ };
162
+ }
163
+
164
+ const provision = defineCommand({
165
+ meta: {
166
+ name: "provision",
167
+ description: "Create the storage buckets, write the R2 credentials, and deploy the sweep workers",
168
+ },
169
+ args: {
170
+ "api-token": {
171
+ type: "string",
172
+ description:
173
+ "Cloudflare API token carried alongside the R2 key pair, so the object store can prove bucket access. Defaults to CLOUDFLARE_API_TOKEN from .dev.vars — a broad token; supply an R2-scoped one for production.",
174
+ },
175
+ "r2-access-key-id": {
176
+ type: "string",
177
+ description:
178
+ "R2 S3 access key id the Worker presigns uploads and downloads with. Create the pair under R2 → Manage API tokens. Falls back to R2_CREDENTIALS in the account config.",
179
+ },
180
+ "r2-secret-access-key": {
181
+ type: "string",
182
+ description:
183
+ "R2 S3 secret access key, paired with --r2-access-key-id. Falls back to R2_CREDENTIALS in the account config.",
184
+ },
185
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
186
+ },
187
+ run: ({ args }) =>
188
+ withErrorReporting(args.json, async () => {
189
+ const projectDir = process.cwd();
190
+ // The leading segment of every name this run creates — the bucket, the sweep worker, the
191
+ // Workflow. `requireProjectName` refuses to guess, because `deprovision` recomputes these same
192
+ // names to find what to delete (docs/NAMING.md).
193
+ const config = await loadProject(projectDir);
194
+ const project = requireProjectName(config);
195
+ // The project's own environment set (#241): what this command fans out across, rather than a
196
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
197
+ const environments = loadProjectEnvironments(config);
198
+ const { provisionStorage } = await loadStorage();
199
+ const { account, accountId, apiToken, storeId, r2Raw } = loadCloudflareCreds(
200
+ await projectCloudflareAccount(projectDir),
201
+ );
202
+ const storageConfig = await loadStorageConfig(projectDir);
203
+ const r2Credentials = resolveR2Credentials(args["r2-access-key-id"], args["r2-secret-access-key"], r2Raw);
204
+ const cf = new CloudflareClients({ accountId, apiToken });
205
+ const provisioner = new CloudflareStorageProvisioner({
206
+ cf,
207
+ project,
208
+ environments,
209
+ account,
210
+ apiToken,
211
+ storeId,
212
+ storageApiToken: args["api-token"] ?? apiToken,
213
+ r2Credentials,
214
+ storageConfig,
215
+ dispatcher: buildSecretDispatcher(accountId, apiToken, project),
216
+ resolveEnv: buildResolveEnv(projectDir, cf, project, account),
217
+ audit: await buildAudit(projectDir, accountId, apiToken),
218
+ });
219
+
220
+ const result = await provisionStorage(provisioner, environments);
221
+
222
+ // Only now can the sweep's Workflow binding be written. `pithy add storage` cannot: wrangler
223
+ // requires a `name` and a `class_name` on every `workflows` entry, and the deployed Workflow name
224
+ // is per project and environment (`<project>-<env>-storage-sweep`). An entry short of either field fails the whole
225
+ // config, so `add` emits none and this completes it — see capabilities/add.ts.
226
+ const { storageWorkflowRegistry, STORAGE_CAPABILITY } = await loadStorage();
227
+ for (const entry of result.environments) {
228
+ await applyAppBindings(projectDir, entry.env, {
229
+ workflows: appWorkflowBindings(storageWorkflowRegistry, {
230
+ project,
231
+ capability: STORAGE_CAPABILITY,
232
+ env: entry.env,
233
+ }),
234
+ });
235
+ }
236
+
237
+ if (args.json) {
238
+ process.stdout.write(`${formatJsonLine({ command: "storage provision", ...result })}\n`);
239
+ return;
240
+ }
241
+ for (const entry of result.environments) {
242
+ process.stdout.write(`${entry.env}: bucket ${entry.bucketName} ready, sweep worker deployed.\n`);
243
+ }
244
+ process.stdout.write(`${formatDone()}\n`);
245
+ }),
246
+ });
247
+
248
+ const deprovision = defineCommand({
249
+ meta: { name: "deprovision", description: "Remove the sweep workers (and optionally the buckets)" },
250
+ args: {
251
+ storage: {
252
+ type: "boolean",
253
+ default: false,
254
+ description: "Also delete the R2 buckets and every file in them (irreversible)",
255
+ },
256
+ "r2-access-key-id": {
257
+ type: "string",
258
+ description:
259
+ "R2 S3 access key id, required with --storage: a bucket must be emptied over the S3 protocol before R2 will delete it. Falls back to R2_CREDENTIALS in the account config.",
260
+ },
261
+ "r2-secret-access-key": {
262
+ type: "string",
263
+ description:
264
+ "R2 S3 secret access key, paired with --r2-access-key-id. Falls back to R2_CREDENTIALS in the account config.",
265
+ },
266
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
267
+ },
268
+ run: ({ args }) =>
269
+ withErrorReporting(args.json, async () => {
270
+ const projectDir = process.cwd();
271
+ // Teardown finds resources by recomputing their names, so this must be the same name
272
+ // `provision` used. A guess would match nothing, delete nothing, and still exit 0.
273
+ const config = await loadProject(projectDir);
274
+ const project = requireProjectName(config);
275
+ // The project's own environment set (#241): what this command fans out across, rather than a
276
+ // pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
277
+ const environments = loadProjectEnvironments(config);
278
+ const { deprovisionStorage } = await loadStorage();
279
+ const { account, accountId, apiToken, r2Raw } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
280
+ // Resolve the key pair up front, before a single worker comes down. A bucket cannot be deleted
281
+ // without it, so discovering it is missing at the bucket step would leave the sweep workers gone
282
+ // and the buckets standing — a half-torn-down environment for a mistake we can catch here.
283
+ const r2Credentials = args.storage
284
+ ? resolveR2Credentials(args["r2-access-key-id"], args["r2-secret-access-key"], r2Raw)
285
+ : undefined;
286
+ const cf = new CloudflareClients({ accountId, apiToken });
287
+ const deprovisioner = new CloudflareStorageDeprovisioner({
288
+ account,
289
+ cf,
290
+ project,
291
+ r2Credentials,
292
+ audit: await buildAudit(projectDir, accountId, apiToken),
293
+ });
294
+
295
+ await deprovisionStorage(deprovisioner, environments, { deleteStorage: args.storage });
296
+
297
+ if (args.json) {
298
+ process.stdout.write(`${formatJsonLine({ command: "storage deprovision", storageDeleted: args.storage })}\n`);
299
+ return;
300
+ }
301
+ process.stdout.write(`Sweep workers removed${args.storage ? ", including the buckets and their files" : ""}.\n`);
302
+ process.stdout.write(`${formatDone()}\n`);
303
+ }),
304
+ });
305
+
306
+ export default defineCommand({
307
+ meta: { name: "storage", description: "Provision and manage the storage infrastructure" },
308
+ subCommands: { provision, deprovision },
309
+ });