@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,652 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { DEFAULT_ENVIRONMENTS, type DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
7
+ import { environmentScope } from "@pithy-sh/core/src/naming/provisionScope";
8
+ import {
9
+ environmentsWrittenBeforeFailure,
10
+ type SecretDispatcher,
11
+ type SecretProbe,
12
+ type SecretRotationRecorder,
13
+ } from "@pithy-sh/secrets/src/cli/dispatch";
14
+ import { secretWriteTargets } from "@pithy-sh/secrets/src/cli/writeTargets";
15
+ import { deprovisionSecrets, provisionSecrets } from "@pithy-sh/secrets/src/provision/provisionSecrets";
16
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
17
+ import { canonicalGlobalEnvironment, type ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
18
+ import { defineCommand } from "citty";
19
+ import { createProjectCliAudit } from "../audit/cliAudit";
20
+ import {
21
+ type MintedSecret,
22
+ mintDeclaredSecrets,
23
+ mintedBeforeFailure,
24
+ mintReportLines,
25
+ storeSecretMinter,
26
+ } from "../capabilities/mintSecrets";
27
+ import {
28
+ EXIT_ROLLED_NOT_RECORDED,
29
+ rotationReportLines,
30
+ runSecretRotation,
31
+ unrecordedFailure,
32
+ } from "../capabilities/rotateSecrets";
33
+ import { resolveSecretRegistry, runSecretWrite } from "../capabilities/secrets";
34
+ import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
35
+ import {
36
+ buildManagerDeploy,
37
+ CloudflareSecretsDeprovisioner,
38
+ CloudflareSecretsProvisioner,
39
+ } from "../capabilities/secretsProvisioner";
40
+ import type { ConfirmedAccount } from "../cloudflare/accountAnswer";
41
+ import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
42
+ import { editDevSecrets } from "../devSecrets/edit";
43
+ import { resolveDevSecretsFile } from "../devSecrets/location";
44
+ import { mergedSecretRegistry, resolveDevSecretsTargets } from "../devSecrets/targets";
45
+ import { loadProject, projectCloudflareAccount, projectEnvironments, requireProjectName } from "../project/config";
46
+ import { requireManagedEnvironment } from "../project/environment";
47
+ import { resolveWorkers } from "../project/workerScope";
48
+ import { secretsStoreBindings, workerSecretRegistry } from "../provision/secretBindings";
49
+ import { cloudflareSecretsStore } from "../provision/store";
50
+ import { applySecretBindings } from "../provision/wranglerEnv";
51
+ import {
52
+ formatDone,
53
+ formatError,
54
+ formatErrorJson,
55
+ formatJsonLine,
56
+ formatList,
57
+ withErrorReporting,
58
+ } from "../terminal/output";
59
+
60
+ /**
61
+ * The secret registry for the whole project: every Worker's, merged by secret name.
62
+ *
63
+ * Capabilities are per Worker, so the registry is too. The secret **name** is the join key — the same name
64
+ * resolves the same value through any registry that declares it — so `pithy secrets` must see every declared
65
+ * name, not just the alphabetically-first Worker's. A Worker that does not compose `secrets` simply
66
+ * contributes nothing; when no Worker does, the capability's own actionable error is what surfaces.
67
+ */
68
+ async function projectSecretRegistry(projectDir: string): Promise<SecretRegistry> {
69
+ const workers = await resolveWorkers({ projectDir });
70
+ const registries: SecretRegistry[] = [];
71
+ let absent: unknown;
72
+ for (const worker of workers) {
73
+ try {
74
+ registries.push(resolveSecretRegistry(worker.config));
75
+ } catch (error) {
76
+ absent = error;
77
+ }
78
+ }
79
+ const first = registries[0];
80
+ if (!first) throw absent;
81
+ return registries.length === 1 ? first : (Object.assign({}, ...registries) as SecretRegistry);
82
+ }
83
+
84
+ /**
85
+ * The audit emitter for a secrets command. Every value-touching write is a warning-severity event
86
+ * (CLAUDE.md §Security), so this is built for every write and provisioning call — a no-op when
87
+ * Cloudflare credentials or the audit capability aren't there, never a blocker.
88
+ */
89
+ async function buildAudit(projectDir: string, env: string) {
90
+ const vars = cloudflareEnv({ account: await projectCloudflareAccount(projectDir) });
91
+ // Auditing spans the project, not one Worker: `audit` composed anywhere means the trail exists. Here
92
+ // `env` really is the environment acted on, so it is also the recorded origin.
93
+ return createProjectCliAudit({
94
+ projectDir,
95
+ accountId: vars.CLOUDFLARE_ACCOUNT_ID,
96
+ apiToken: vars.CLOUDFLARE_API_TOKEN,
97
+ env,
98
+ actedOn: env,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * The dispatcher a `--dry-run` gets: every seam present, none of them reachable, and none of them called.
104
+ *
105
+ * A dry run answers what *would* happen and must reach no account at all — which is what makes it usable
106
+ * before the credentials exist. `rotateSecretValue` returns `unchanged` before it opens a rotation row, so
107
+ * `openRotation` here is unreachable rather than merely unused; it throws instead of returning a plausible
108
+ * id, because a dry run that quietly recorded a rotation would be the one thing it promises never to do.
109
+ */
110
+ const DRY_RUN_DISPATCHER: SecretDispatcher & SecretRotationRecorder = {
111
+ dispatch: async () => {},
112
+ openRotation: async () => {
113
+ throw new ValidationError({
114
+ message: "A dry run does not record a rotation.",
115
+ detail: "DRY_RUN_DISPATCHER.openRotation was reached, which means a dry run passed the ledger open",
116
+ });
117
+ },
118
+ closeRotation: async () => {},
119
+ };
120
+
121
+ /**
122
+ * Build the live dispatcher from CF creds (`.dev.vars`, then `process.env`) and the project name.
123
+ *
124
+ * `requireProjectName`, never `resolveProjectName`: the target Workflow is `<project>-<env>-secrets-write`
125
+ * and Workflow names are account-scoped, so a fallback-derived name would either dispatch nowhere or
126
+ * dispatch this project's values into another project's manager.
127
+ */
128
+ async function buildDispatcher(projectDir: string): Promise<SecretDispatcher & SecretProbe & SecretRotationRecorder> {
129
+ const { accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
130
+ const project = requireProjectName(await loadProject(projectDir));
131
+ return buildSecretDispatcher(accountId, apiToken, project);
132
+ }
133
+
134
+ /** The CF credentials and Secrets Store id provisioning needs, from `.dev.vars` then `process.env`. */
135
+ function loadCloudflareCreds(
136
+ account: CloudflareAccountSelection | null,
137
+ options: { requireStore?: boolean } = {},
138
+ ): {
139
+ account: ConfirmedAccount;
140
+ accountId: string;
141
+ apiToken: string;
142
+ storeId: string;
143
+ } {
144
+ const vars = cloudflareEnv({ account });
145
+ const confirmation = cloudflareAccountConfirmation({ account });
146
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
147
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
148
+ const storeId = vars.SECRETS_STORE_ID ?? "";
149
+ if (!accountId || !apiToken) {
150
+ throw new ValidationError({
151
+ message: "Cloudflare credentials are missing.",
152
+ action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
153
+ });
154
+ }
155
+ if (options.requireStore && !storeId) {
156
+ throw new ValidationError({
157
+ message: "The CF Secrets Store id is missing.",
158
+ action: "Run pithy add secrets to record SECRETS_STORE_ID (create a Secrets Store in the Cloudflare dashboard).",
159
+ });
160
+ }
161
+ return { account: { accountId, confirmation }, accountId, apiToken, storeId };
162
+ }
163
+
164
+ /**
165
+ * Read the secret value: from stdin when it is piped (agent/non-interactive use), otherwise from a
166
+ * masked prompt. Never from a flag — a value there would persist in shell history and process lists.
167
+ */
168
+ async function readValue(name: string): Promise<string> {
169
+ if (!process.stdin.isTTY) {
170
+ const chunks: Buffer[] = [];
171
+ for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
172
+ return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
173
+ }
174
+ const { isCancel, password } = await import("@clack/prompts");
175
+ const answer = await password({ message: `Value for '${name}'` });
176
+ if (isCancel(answer)) {
177
+ process.stderr.write("Canceled.\n");
178
+ process.exit(1);
179
+ }
180
+ return answer;
181
+ }
182
+
183
+ /**
184
+ * **Ask the rule before asking for a value.**
185
+ *
186
+ * `secretWriteTargets` is what decides whether a write is coherent, and `dispatchSecretWrite` asks it
187
+ * again with nothing sent if the answer is no — that second call is what makes it a guarantee rather
188
+ * than a courtesy. This one exists so an operator is not prompted to type a production signing key into
189
+ * a command that was never going to run.
190
+ *
191
+ * The refusals `runSecretWrite` owns — an undeclared name, a keyspace — are left to it. Answering them
192
+ * here as well would be a second producer of two more rules.
193
+ */
194
+ function checkWriteIsCoherent(
195
+ registry: SecretRegistry,
196
+ mode: "create" | "update" | "delete",
197
+ name: string,
198
+ requested: ManagedEnvironment | undefined,
199
+ declared: DeclaredEnvironments,
200
+ ): void {
201
+ const entry = registry[name];
202
+ if (!entry || entry.keyed) return;
203
+ secretWriteTargets({ name, backend: entry.backend, scope: entry.scope, mode, requested, declared });
204
+ }
205
+
206
+ /**
207
+ * The environment a write's **audit** is recorded from — never the write's target, which
208
+ * `secretWriteTargets` decides from what the operator actually typed.
209
+ *
210
+ * A global write has no single origin, so the canonical environment stands in: the trail has to name
211
+ * somewhere, and the canonical one is the manager a `cf-secrets-store` write goes through anyway. The
212
+ * environments a run *reached* are in the event's metadata, which is the field that answers where a
213
+ * value landed.
214
+ */
215
+ function auditOrigin(requested: ManagedEnvironment | undefined, declared: DeclaredEnvironments): string {
216
+ return requested ?? canonicalGlobalEnvironment(declared) ?? declared[0] ?? "prod";
217
+ }
218
+
219
+ /** Shared body for create/update/rm: discover the registry, dispatch, and report the envs written. */
220
+ async function write(
221
+ mode: "create" | "update" | "delete",
222
+ args: { name: string; env?: string; json: boolean },
223
+ ): Promise<void> {
224
+ const projectDir = process.cwd();
225
+ const registry = await projectSecretRegistry(projectDir);
226
+ const environments = await projectEnvironments(projectDir);
227
+ // `--env` as the operator gave it, or nothing. Not resolved to a default: the absence is the whole
228
+ // difference between *narrow this write* and *say nothing*, and it is what the rule turns on.
229
+ const env = args.env ? requireManagedEnvironment(args.env, environments) : undefined;
230
+ checkWriteIsCoherent(registry, mode, args.name, env, environments);
231
+ const value = mode === "delete" ? undefined : await readValue(args.name);
232
+ const dispatcher = await buildDispatcher(projectDir);
233
+ const audit = await buildAudit(projectDir, auditOrigin(env, environments));
234
+
235
+ let targets: ManagedEnvironment[];
236
+ try {
237
+ targets = await runSecretWrite(registry, dispatcher, { mode, name: args.name, value, env, environments }, audit);
238
+ } catch (error) {
239
+ // **A fan-out has no rollback, so what it wrote is said before the error is.** Three environments and
240
+ // the third throws leaves the first two holding the new value; without this the operator reads a
241
+ // failure and has no way to know that. `withErrorReporting` then puts `{ error }` on stderr and exits
242
+ // 1 — the streams agree, and neither reports success.
243
+ const written = environmentsWrittenBeforeFailure(error);
244
+ if (written.length > 0) {
245
+ const landed = mode === "delete" ? "removed from" : "written to";
246
+ process.stdout.write(
247
+ args.json
248
+ ? `${formatJsonLine({ command: `secrets ${mode}`, name: args.name, environments: written, interrupted: true })}\n`
249
+ : `${args.name} ${landed} ${written.join(", ")} before this failed.\n`,
250
+ );
251
+ }
252
+ throw error;
253
+ }
254
+
255
+ if (args.json) {
256
+ process.stdout.write(`${formatJsonLine({ command: `secrets ${mode}`, name: args.name, environments: targets })}\n`);
257
+ return;
258
+ }
259
+ process.stdout.write(`${args.name} ${mode === "delete" ? "removed from" : "written to"} ${targets.join(", ")}.\n`);
260
+ process.stdout.write(`${formatDone()}\n`);
261
+ }
262
+
263
+ const nameArg = {
264
+ name: { type: "positional", required: true, description: "Secret name (a registry entry)." },
265
+ } as const;
266
+ const sharedArgs = {
267
+ env: {
268
+ type: "string",
269
+ // Resolved when the command tree is built, before any project is read, so this names the default set
270
+ // and the refusal names the project's own — see `requireManagedEnvironment`.
271
+ description: `Target environment for an environment-scoped secret: ${DEFAULT_ENVIRONMENTS.join(" | ")}, or one declared in pithy.config.ts`,
272
+ },
273
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
274
+ } as const;
275
+
276
+ const create = defineCommand({
277
+ meta: { name: "create", description: "Create a secret (fails if it already exists)" },
278
+ args: { ...nameArg, ...sharedArgs },
279
+ run: ({ args }) => withErrorReporting(args.json, () => write("create", args)),
280
+ });
281
+
282
+ const update = defineCommand({
283
+ meta: { name: "update", description: "Update a secret (fails if it doesn't exist)" },
284
+ args: { ...nameArg, ...sharedArgs },
285
+ run: ({ args }) => withErrorReporting(args.json, () => write("update", args)),
286
+ });
287
+
288
+ const rm = defineCommand({
289
+ meta: { name: "rm", description: "Remove a secret" },
290
+ args: { ...nameArg, ...sharedArgs },
291
+ run: ({ args }) => withErrorReporting(args.json, () => write("delete", args)),
292
+ });
293
+
294
+ /**
295
+ * `pithy secrets rotate` — replace one secret's value against the rotation its registry entry declares.
296
+ *
297
+ * **One secret per invocation. There is no `--all`, and that is a decision rather than an omission.**
298
+ *
299
+ * The case that wants one is real: somebody has left, and every credential they could have seen needs
300
+ * rolling today. The dashboard solved the same problem for connection signing keys and settled on *more
301
+ * than one confirmation, plus an audit entry naming the operator* — and the second half is the half this
302
+ * command cannot honor. `createCliAudit` resolves the actor from the Cloudflare API token and falls back
303
+ * to `system, actorResolutionFailed` when there is none, so the one act most certain to be reviewed
304
+ * afterwards would be recorded as *somebody with the token*. A fleet path that cannot say who took it is
305
+ * worse than no fleet path, because it is the difference between an incident with a name on it and an
306
+ * incident without one.
307
+ *
308
+ * The blast radius argues the same way from the other end. The failure this command is built around —
309
+ * rolled at the issuer, not recorded — does not average out over ten secrets; it is ten chances to strand
310
+ * a live credential inside one invocation, reported into one scrollback, at the hour an operator is least
311
+ * able to read carefully. A flag one character from the ordinary command is the wrong place for that.
312
+ *
313
+ * **What the case gets instead**: `pithy secrets ls` names every declared secret, and a shell loop over it
314
+ * makes the operator see the list they are about to roll before they roll it. That is a worse ergonomic
315
+ * and a better 2am.
316
+ *
317
+ * `--dry-run` is here because it costs almost nothing and answers the question an operator has just before
318
+ * the irreversible one: *is this secret rolled at somebody else's API, or minted here?*
319
+ */
320
+ const rotate = defineCommand({
321
+ meta: { name: "rotate", description: "Rotate one secret against its declared rotator" },
322
+ args: {
323
+ ...nameArg,
324
+ ...sharedArgs,
325
+ "dry-run": { type: "boolean", default: false, description: "Say what would happen; call nothing" },
326
+ },
327
+ run: ({ args }) =>
328
+ withErrorReporting(args.json, async () => {
329
+ const projectDir = process.cwd();
330
+ const registry = await projectSecretRegistry(projectDir);
331
+ const environments = await projectEnvironments(projectDir);
332
+ const env = args.env ? requireManagedEnvironment(args.env, environments) : undefined;
333
+ const entry = registry[args.name];
334
+ const dryRun = args["dry-run"];
335
+
336
+ // **The dispatcher is built before anything is rolled**, and the ordering is load-bearing rather
337
+ // than tidy. Missing credentials raise here, with the previous value untouched; built after the
338
+ // roll, the same missing credentials would strand a live one behind a message about `pithy init`.
339
+ // A dry run reaches no account at all, which is what makes it usable before the credentials exist.
340
+ const dispatcher = dryRun ? DRY_RUN_DISPATCHER : await buildDispatcher(projectDir);
341
+ const audit = dryRun ? async () => {} : await buildAudit(projectDir, auditOrigin(env, environments));
342
+
343
+ const outcome = await runSecretRotation(
344
+ registry,
345
+ dispatcher,
346
+ { name: args.name, env, environments, dryRun },
347
+ audit,
348
+ );
349
+ // `runSecretRotation` refuses an undeclared name before anything else happens, so reaching here with
350
+ // no entry is impossible — this narrows for the type checker rather than for a state that can occur.
351
+ if (!entry) throw new ValidationError({ message: `Secret '${args.name}' is not declared in the registry.` });
352
+
353
+ if (args.json) {
354
+ const rotations = [
355
+ {
356
+ name: outcome.name,
357
+ status: outcome.status,
358
+ rotation: outcome.kind,
359
+ rolled: outcome.rolled,
360
+ ...(outcome.rollFailed === undefined ? {} : { rollFailed: outcome.rollFailed }),
361
+ recorded: outcome.recorded,
362
+ stranded: outcome.stranded,
363
+ ...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
364
+ },
365
+ ];
366
+ process.stdout.write(`${formatJsonLine({ command: "secrets rotate", name: args.name, rotations })}\n`);
367
+ } else {
368
+ for (const line of rotationReportLines(entry, outcome, env)) process.stdout.write(`${line}\n`);
369
+ }
370
+
371
+ // **The two ends of the same run, and they must never disagree.** Whatever the outcome, stdout has
372
+ // already said per secret what happened; these decide what the shell learns.
373
+ if (outcome.status === "unrecorded") {
374
+ const failure = unrecordedFailure(entry, outcome, env);
375
+ process.stderr.write(`${args.json ? formatErrorJson(failure.payload) : formatError(failure.payload)}\n`);
376
+ // Not a throw: `withErrorReporting` would exit 1, and 1 is the status that means *the previous
377
+ // credential is still live*. This state is the one thing in the command that is not that.
378
+ process.exitCode = EXIT_ROLLED_NOT_RECORDED;
379
+ return;
380
+ }
381
+ if (outcome.status === "failed") {
382
+ // Ordinary: nothing was rolled, so the previous value is still live and the run can be repeated.
383
+ // The cause is what the operator needs, and `withErrorReporting` puts it on stderr with exit 1. The
384
+ // fallback is not decoration — `throw undefined` would exit non-zero with a blank stderr, which is
385
+ // the one report worse than a bad one.
386
+ throw (
387
+ outcome.cause ??
388
+ new ValidationError({
389
+ message: `Secret '${args.name}' was not rotated.`,
390
+ action: "Run it again. The previous value is still live.",
391
+ detail: `rotate '${args.name}': store refused with no recorded cause`,
392
+ })
393
+ );
394
+ }
395
+ if (args.json) return;
396
+ if (dryRun) {
397
+ process.stdout.write("Dry run. Nothing rolled, nothing written.\n");
398
+ return;
399
+ }
400
+ // **No `Done.` over a `manual` secret.** The lines above have just told the operator that a human has
401
+ // to go to a console, and `Done.` under them reads as the command having handled it. The last thing
402
+ // they see is the instruction, which is the only thing left to act on.
403
+ if (outcome.reason === "manual") return;
404
+ process.stdout.write(`${formatDone()}\n`);
405
+ }),
406
+ });
407
+
408
+ const ls = defineCommand({
409
+ meta: { name: "ls", description: "List the declared secrets" },
410
+ args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
411
+ run: ({ args }) =>
412
+ withErrorReporting(args.json, async () => {
413
+ const registry = await projectSecretRegistry(process.cwd());
414
+ const rows = Object.entries(registry)
415
+ .sort(([a], [b]) => a.localeCompare(b))
416
+ .map(([name, entry]) => ({
417
+ name,
418
+ // A keyspace is marked, because it is the one entry an operator must not try to set: its
419
+ // members are written per key by the application that mints them.
420
+ description: `${entry.backend} · ${entry.scope}${entry.rotatable ? " · rotatable" : ""}${entry.keyed ? " · keyspace" : ""}`,
421
+ }));
422
+ if (args.json) {
423
+ process.stdout.write(`${formatJsonLine({ command: "secrets ls", secrets: rows })}\n`);
424
+ return;
425
+ }
426
+ process.stdout.write(`${formatList(rows)}\n`);
427
+ }),
428
+ });
429
+
430
+ /**
431
+ * `pithy secrets edit` — the local dev values, in the adopter's editor (#157).
432
+ *
433
+ * The odd one out in this file, and deliberately: every other subcommand here writes a **managed**
434
+ * secret through the manager Workflow, and this one touches nothing but the machine-local file at
435
+ * `<config>/<project>/secrets.jsonc`. They are siblings because they are the same question — "where does
436
+ * this value live" — asked about the two environments a project has.
437
+ *
438
+ * It resolves the path, opens it, validates what comes back, and writes it atomically at `0600`. It
439
+ * prints a path and a count, and never a name or a value: `secrets ls` is what lists names.
440
+ */
441
+ const edit = defineCommand({
442
+ meta: { name: "edit", description: "Edit this machine's dev secret values in your editor" },
443
+ args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
444
+ run: ({ args }) =>
445
+ withErrorReporting(args.json, async () => {
446
+ // The one resolution of where the file is (`devSecrets/location.ts`). It requires a project name
447
+ // rather than guessing one: a guess would open one checkout's secrets from another's worktree.
448
+ const path = await resolveDevSecretsFile(process.cwd());
449
+ // Best effort, and never a reason to refuse (#323). With a registry an edit is judged against the
450
+ // payload each secret's destination takes; without one the file is still checked as JSONC. A
451
+ // project whose config will not load is the state this command exists to get somebody out of.
452
+ const targets = await resolveDevSecretsTargets(process.cwd())
453
+ .then((resolved) => resolved.targets)
454
+ .catch(() => []);
455
+ const result = await editDevSecrets({ path, registry: mergedSecretRegistry(targets) });
456
+
457
+ if (args.json) {
458
+ process.stdout.write(
459
+ `${formatJsonLine({ command: "secrets edit", path, changed: result.changed, secrets: result.secrets })}\n`,
460
+ );
461
+ return;
462
+ }
463
+ process.stdout.write(
464
+ result.changed
465
+ ? `${path} written. ${result.secrets} ${result.secrets === 1 ? "secret" : "secrets"}.\n`
466
+ : `${path} unchanged.\n`,
467
+ );
468
+ process.stdout.write(`${formatDone()}\n`);
469
+ }),
470
+ });
471
+
472
+ const provision = defineCommand({
473
+ meta: { name: "provision", description: "Provision the per-environment secrets infrastructure" },
474
+ args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
475
+ run: ({ args }) =>
476
+ withErrorReporting(args.json, async () => {
477
+ const projectDir = process.cwd();
478
+ const { account, accountId, apiToken, storeId } = loadCloudflareCreds(
479
+ await projectCloudflareAccount(projectDir),
480
+ {
481
+ requireStore: true,
482
+ },
483
+ );
484
+ // Never `resolveProjectName`: every Secrets Store entry and the manager's token name derive from
485
+ // this, and deprovision has to recompute them exactly. A guessed name would name resources
486
+ // teardown can never find again.
487
+ const project = requireProjectName(await loadProject(projectDir));
488
+ const cf = new CloudflareClients({ accountId, apiToken });
489
+ // Provisioning spans every managed environment, not one — "dev" is the fallback the audit
490
+ // database resolves against when a command has no single target env (mirrors `pithy feature`).
491
+ const provisioner = new CloudflareSecretsProvisioner({
492
+ cf,
493
+ account,
494
+ project,
495
+ storeId,
496
+ deploy: buildManagerDeploy({ accountId, apiToken, project }),
497
+ audit: await buildAudit(projectDir, "dev"),
498
+ });
499
+
500
+ const environments = await projectEnvironments(projectDir);
501
+ const result = await provisionSecrets(provisioner, environments);
502
+
503
+ // **The step the deferral was deferring to (#238).** `pithy add` cannot write a `secret` binding —
504
+ // the entry needs a `store_id` and a `secret_name` that do not exist until an account has been
505
+ // reached — and `ensureSecretsStoreId` records nothing in five cases besides. Provisioning is when
506
+ // the store certainly exists and every entry has certainly been written, so this is where the
507
+ // adopter's own Workers get the stanza. It corrects an existing entry rather than duplicating it,
508
+ // and leaves a binding this registry does not declare exactly where the adopter put it.
509
+ //
510
+ // `dev` is deliberately not among them: local dev materialises every `cf-secrets-store` secret into
511
+ // the generated `.dev.vars` (#179), so a stanza there would name entries a local run never reads.
512
+ const store = cloudflareSecretsStore(cf, storeId);
513
+ // One emitter per environment, resolved once: `buildAudit` reaches the account and the Worker set,
514
+ // and it is the same answer for every Worker in a given environment.
515
+ const audits = new Map(
516
+ await Promise.all(environments.map(async (env) => [env, await buildAudit(projectDir, env)] as const)),
517
+ );
518
+ const wired: { worker: string; env: string; bindings: string[]; created: string[] }[] = [];
519
+ for (const worker of await resolveWorkers({ projectDir })) {
520
+ const registry = workerSecretRegistry(worker.capabilities);
521
+ if (!registry) continue;
522
+ for (const env of environments) {
523
+ const { bound, minted } = await secretsStoreBindings({
524
+ registry,
525
+ scope: environmentScope(project, env),
526
+ storeId,
527
+ exists: (name) => store.exists(name),
528
+ // Every environment's master key exists by now, so this is the point where a declared
529
+ // mintable secret can be created and bound in one pass rather than named as homework (#321).
530
+ mint: storeSecretMinter({
531
+ store,
532
+ environment: env,
533
+ ...(audits.get(env) ? { audit: audits.get(env) } : {}),
534
+ }),
535
+ });
536
+ if (bound.length === 0) continue;
537
+ await applySecretBindings(worker.dir, env, bound);
538
+ wired.push({ worker: worker.name, env, bindings: bound.map((entry) => entry.binding), created: minted });
539
+ }
540
+ }
541
+
542
+ // **The other half of #321, and the half its own commit message describes.** The loop above creates
543
+ // the `cf-secrets-store` secrets a Worker binds; every secret the *kit* declares arbitrary — the
544
+ // auth session secret, the email link-signing key — is `d1`, and until now provisioning finished by
545
+ // telling an operator to go and generate random bytes for each. This is the point where it can stop
546
+ // doing that: `provisionSecrets` above has deployed each environment's manager, and the manager is
547
+ // the only thing that can decide whether one of these already exists, because its value is sealed
548
+ // under a master key the CLI never holds. So the managers are **asked** first, across every
549
+ // environment at once, and only then written to. See `capabilities/mintSecrets.ts`.
550
+ //
551
+ // One audit emitter is picked rather than one per environment: this loop spans every environment
552
+ // a `global` secret reaches, and the event's own `environments` field is what says where a value
553
+ // went. `dev` is the same fallback `buildAudit` above uses for a command with no single target.
554
+ //
555
+ // **A run that fails here has usually written something.** The fan-out creates a signing key per
556
+ // environment, so a fault after the first write leaves key material behind — and until #324 the
557
+ // report of it was assembled after the loop and died with the throw. So what landed is caught and
558
+ // printed *before* the error is rethrown: `withErrorReporting` then writes the failure to stderr
559
+ // and exits 1, and stdout carries what the run actually did. Both streams, and the exit code,
560
+ // agree that it failed and name what it wrote on the way.
561
+ const managers = await buildDispatcher(projectDir);
562
+ let generated: MintedSecret[];
563
+ try {
564
+ generated = await mintDeclaredSecrets({
565
+ registry: await projectSecretRegistry(projectDir),
566
+ dispatcher: managers,
567
+ probe: managers,
568
+ environments,
569
+ audit: await buildAudit(projectDir, "dev"),
570
+ });
571
+ } catch (error) {
572
+ const landed = mintedBeforeFailure(error);
573
+ if (args.json) {
574
+ process.stdout.write(
575
+ `${formatJsonLine({
576
+ command: "secrets provision",
577
+ environments: result.perEnv,
578
+ wired,
579
+ generated: landed,
580
+ interrupted: true,
581
+ })}\n`,
582
+ );
583
+ } else {
584
+ for (const line of mintReportLines(landed)) process.stdout.write(`${line}\n`);
585
+ }
586
+ throw error;
587
+ }
588
+
589
+ if (args.json) {
590
+ process.stdout.write(
591
+ `${formatJsonLine({ command: "secrets provision", environments: result.perEnv, wired, generated })}\n`,
592
+ );
593
+ return;
594
+ }
595
+ for (const env of result.perEnv) {
596
+ process.stdout.write(`${env.env}: database, key, and manager ready.\n`);
597
+ }
598
+ for (const entry of wired) {
599
+ process.stdout.write(`${entry.worker} env.${entry.env} binds ${entry.bindings.join(", ")}.\n`);
600
+ if (entry.created.length > 0) {
601
+ process.stdout.write(`${entry.env}: created ${entry.created.join(", ")}.\n`);
602
+ }
603
+ }
604
+ // It used to say only "ready", because the manager decided whether a value was written and never
605
+ // reported back. It reports now, so the run can say the thing an operator actually needs to know:
606
+ // whether this run generated a production signing key, or found one already there. The same
607
+ // renderer as the interrupted path above — one phrasing, so a partial report cannot read as a
608
+ // complete one.
609
+ for (const line of mintReportLines(generated)) process.stdout.write(`${line}\n`);
610
+ process.stdout.write(`${formatDone()}\n`);
611
+ }),
612
+ });
613
+
614
+ const deprovision = defineCommand({
615
+ meta: { name: "deprovision", description: "Remove the secrets manager workers and databases" },
616
+ args: {
617
+ keys: { type: "boolean", default: false, description: "Also delete the master keys (irreversible)" },
618
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
619
+ },
620
+ run: ({ args }) =>
621
+ withErrorReporting(args.json, async () => {
622
+ const projectDir = process.cwd();
623
+ const { account, accountId, apiToken, storeId } = loadCloudflareCreds(
624
+ await projectCloudflareAccount(projectDir),
625
+ {
626
+ requireStore: true,
627
+ },
628
+ );
629
+ const cf = new CloudflareClients({ accountId, apiToken });
630
+ const deprovisioner = new CloudflareSecretsDeprovisioner({
631
+ account,
632
+ cf,
633
+ project: requireProjectName(await loadProject(projectDir)),
634
+ storeId,
635
+ audit: await buildAudit(projectDir, "dev"),
636
+ });
637
+
638
+ await deprovisionSecrets(deprovisioner, await projectEnvironments(projectDir), { deleteKeys: args.keys });
639
+
640
+ if (args.json) {
641
+ process.stdout.write(`${formatJsonLine({ command: "secrets deprovision", keysDeleted: args.keys })}\n`);
642
+ return;
643
+ }
644
+ process.stdout.write(`Secrets infrastructure removed${args.keys ? ", including master keys" : ""}.\n`);
645
+ process.stdout.write(`${formatDone()}\n`);
646
+ }),
647
+ });
648
+
649
+ export default defineCommand({
650
+ meta: { name: "secrets", description: "Manage encrypted secrets" },
651
+ subCommands: { create, update, rotate, rm, ls, edit, provision, deprovision },
652
+ });