@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,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+
6
+ /**
7
+ * **An option a manifest states no `default` for is a required option.** That is the whole model.
8
+ *
9
+ * There is no flag beside a default that could contradict it. An option that states a default has an
10
+ * answer the kit is willing to pick; an option that states none has one only the adopter can — so every
11
+ * path that could write a config either carries a value for it or refuses, and none of them guesses.
12
+ *
13
+ * The defect this closes had one shape in three places. `ConfigOption.default` was a required field, so
14
+ * `promptConfigValues` offered `String(option.default)` as the thing enter accepts, and a `--json` or
15
+ * non-TTY run attached no prompt at all and fell straight through to the manifest's value. `pithy add
16
+ * payments --json` picked a billing model: whether an entitlement is held by a person or a company, into
17
+ * a column and a UNIQUE index, reported as `Done.` A project that meant `organization` found out when it
18
+ * had subscriptions (#412).
19
+ *
20
+ * So the rule lives here, once, and both writers ask it — `pithy add`'s flow before it wires anything,
21
+ * and `pithy upgrade`'s reconcile before it splices a key into a registration it did not write.
22
+ */
23
+
24
+ /** The half of a config option this module needs: what to name, and what it legally takes. */
25
+ export interface RequiredOption {
26
+ /** The option's key, as `--set key=value` spells it. */
27
+ readonly key: string;
28
+ /** The closed set of values it takes, when it states one. */
29
+ readonly choices?: readonly string[] | undefined;
30
+ /** The manifest's default. `undefined` is the declaration that this option is required. */
31
+ readonly default?: unknown;
32
+ }
33
+
34
+ /** Whether this option must be answered before anything can be written for it. */
35
+ export function isRequired(option: RequiredOption): boolean {
36
+ return option.default === undefined;
37
+ }
38
+
39
+ /**
40
+ * The required options this run has not settled, in manifest order.
41
+ *
42
+ * Asked **after** `--set` and any prompt, because those are the two ways of settling one and the answer
43
+ * is the same whichever it came from. A run with a human attached is never refused for a question it was
44
+ * asked; a run with no human is refused for a question nobody could ask it.
45
+ */
46
+ export function unsettledOptions<T extends RequiredOption>(
47
+ options: readonly T[],
48
+ values: Readonly<Record<string, unknown>>,
49
+ ): T[] {
50
+ return options.filter((option) => isRequired(option) && values[option.key] === undefined);
51
+ }
52
+
53
+ /** What {@link requiredOptionRefusal} names: the capability, and each option nobody answered. */
54
+ export interface RequiredOptionRefusalOptions {
55
+ /** The capability being wired, as `pithy add <capability>` spells it. */
56
+ capability: string;
57
+ /** The unsettled options, in manifest order. */
58
+ missing: readonly RequiredOption[];
59
+ }
60
+
61
+ /** `--set key=a or --set key=b` for a closed set, `--set key=<value>` for an open one. */
62
+ function flagsFor(option: RequiredOption): string {
63
+ const choices = option.choices;
64
+ if (choices === undefined || choices.length === 0) return `--set ${option.key}=<value>`;
65
+ const flags = choices.map((choice) => `--set ${option.key}=${choice}`);
66
+ if (flags.length === 1) return flags[0] as string;
67
+ return `${flags.slice(0, -1).join(", ")} or ${flags[flags.length - 1]}`;
68
+ }
69
+
70
+ /** `a`, `a and b`, `a, b and c` — for a sentence, never for a command line. */
71
+ function andList(names: readonly string[]): string {
72
+ if (names.length <= 1) return names.join("");
73
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
74
+ }
75
+
76
+ /**
77
+ * The refusal a run that names no value for a required option takes.
78
+ *
79
+ * Shaped like `prerequisiteRefusal`, and for the same reason: the `message` says what is wrong and the
80
+ * `action` is the operator's, so it names the exact flag and every value that flag takes. An agent
81
+ * driving `pithy add --json` has one line to correct itself from, and "billingSubject is required" is not
82
+ * that line — `--set billingSubject=user or --set billingSubject=organization` is.
83
+ */
84
+ export function requiredOptionRefusal(options: RequiredOptionRefusalOptions): ValidationError {
85
+ const { capability, missing } = options;
86
+ const keys = missing.map((option) => option.key);
87
+ return new ValidationError({
88
+ message: `${capability} needs a value for ${andList(keys)}, and nothing in this run names one.`,
89
+ action: `Pass ${missing.map(flagsFor).join(", and ")}.`,
90
+ detail: `${capability}'s manifest declares ${JSON.stringify(keys)} with no default, which is how a capability says the answer is the adopter's. There is nothing to render and nothing to accept by pressing enter.`,
91
+ });
92
+ }
@@ -0,0 +1,305 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import type { SecretDispatcher, SecretRotationRecorder } from "@pithy-sh/secrets/src/cli/dispatch";
7
+ import { dispatchedRotationLedger } from "@pithy-sh/secrets/src/cli/rotationLedger";
8
+ import { secretWriteTargets } from "@pithy-sh/secrets/src/cli/writeTargets";
9
+ import { SecretRotationUnrecordedError } from "@pithy-sh/secrets/src/error/errors";
10
+ import type { SecretRegistry, SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
11
+ import {
12
+ type RotationSleeper,
13
+ rotateSecretValue,
14
+ type SecretRotationOutcome,
15
+ } from "@pithy-sh/secrets/src/rotation/rotateValue";
16
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
17
+ import type { CliAuditEmit } from "../audit/cliAudit";
18
+
19
+ /**
20
+ * **`pithy secrets rotate` — the command side of the rotation core.**
21
+ *
22
+ * The core (`@pithy-sh/secrets`' `rotateSecretValue`) decides *what happened*; this decides what the
23
+ * operator reads, what the trail records, and what the shell gets back. They are split because a state and
24
+ * the sentence describing it must have one producer each — two would let a partial run be phrased as a
25
+ * complete one, which is the shape #321 and #324 each produced once already.
26
+ *
27
+ * ## Three things this file is responsible for
28
+ *
29
+ * - **A line per secret, never a summary.** {@link rotationReportLines} renders from the outcome and only
30
+ * from the outcome, so there is no path by which "rotated" is printed over an environment that did not
31
+ * take the value.
32
+ * - **The unrecorded state, made unmissable.** {@link unrecordedFailure} turns it into a
33
+ * `secrets/rotation_unrecorded` — its own code, its own exit status, and an action line composed from
34
+ * the declaration, so the operator is told which console to open rather than that something failed.
35
+ * - **An exit code that distinguishes it.** {@link EXIT_ROLLED_NOT_RECORDED}.
36
+ *
37
+ * ## And one thing it deliberately does not do
38
+ *
39
+ * **It never surfaces the value.** Not on a success, not on a failure, not on the failure where the value
40
+ * is about to be lost. The argument, in full, is in `docs/commands/secrets.md`; in short, a live
41
+ * credential written to a terminal is in scrollback, in the CI log, and in whatever recorded the session,
42
+ * permanently — while a rolled-and-unrecorded credential is an outage whose remedy the declaration can
43
+ * name. The core has no field that could carry a value out, so this is structural rather than a habit.
44
+ */
45
+
46
+ /**
47
+ * The exit status for **rolled at the issuer, not recorded**.
48
+ *
49
+ * Distinct from `1`, which every ordinary failure uses, because the two need different reactions and a
50
+ * script cannot tell them apart from a message. `1` means the previous credential is still live and the
51
+ * command can be run again. `3` means it is not, and nothing automated will fix it.
52
+ *
53
+ * `2` is left alone: shells and citty both use it for usage errors, and a status that could mean either
54
+ * "you typed it wrong" or "a production credential is dead" is no signal at all.
55
+ */
56
+ export const EXIT_ROLLED_NOT_RECORDED = 3;
57
+
58
+ /**
59
+ * What a rotation needs from the manager: the write, and the ledger row around it.
60
+ *
61
+ * One object rather than two arguments because a rotation is one act and the two calls must land on the
62
+ * same project's managers. `WorkflowSecretDispatcher` implements both, so this is a description of what is
63
+ * already true rather than a constraint anything has to satisfy separately.
64
+ */
65
+ export type SecretRotationDispatcher = SecretDispatcher & SecretRotationRecorder;
66
+
67
+ /** What the command asks for: one secret, in one environment (or none, for a `global` secret). */
68
+ export interface SecretRotateCommand {
69
+ /** The registry name. */
70
+ name: string;
71
+ /** The environment the operator named with `--env`, or `undefined`. Not defaulted — see `SecretWriteCommand`. */
72
+ env: ManagedEnvironment | undefined;
73
+ /** Every environment the project declares. What a `global` secret fans out across. */
74
+ environments: DeclaredEnvironments | readonly string[];
75
+ /** Resolve the declaration and report what would happen. Calls nothing, writes nothing. */
76
+ dryRun?: boolean;
77
+ /** Store attempts per environment. Defaults to the core's 3. Never a re-roll. */
78
+ attempts?: number;
79
+ /** Injected in tests so a retry costs no real time. */
80
+ sleep?: RotationSleeper;
81
+ }
82
+
83
+ /** The entry, or the same refusal `runSecretWrite` gives for a name the registry has never heard of. */
84
+ function requireEntry(registry: SecretRegistry, name: string): SecretRegistryEntry {
85
+ const entry = registry[name];
86
+ if (!entry) {
87
+ throw new NotFoundError({
88
+ message: `Secret '${name}' is not declared in the registry.`,
89
+ action: "Add it to your secret registry, then run this again.",
90
+ });
91
+ }
92
+ return entry;
93
+ }
94
+
95
+ /**
96
+ * Rotate one secret and record what happened.
97
+ *
98
+ * **The targets come from `secretWriteTargets`**, the same rule `pithy secrets create` and
99
+ * `mintDeclaredSecrets` ask, so a rotation cannot put a value somewhere an update would not — and a
100
+ * `global` secret narrowed with `--env` is refused here for the same reason it is there, before anything
101
+ * is rolled.
102
+ *
103
+ * The audit line is `secrets/rotated`, matching what `pithy secrets update` already emits for the same
104
+ * act, and it carries the name and the environments and nothing else. `unchanged` records nothing: a
105
+ * trail that logs a rotation for a secret nothing touched is a trail that cannot be read.
106
+ *
107
+ * **The rotation ledger is a second record, and not a duplicate of that one.** The audit trail answers
108
+ * *who did what, across the project*; `pithy_secrets_rotations` answers *when was this secret last
109
+ * replaced*, in the environment holding it, and it is what `lastRotatedAt` and every overdue report are
110
+ * read from. `#379` is what it looks like when only the first is written: the command succeeds, the trail
111
+ * records a rotation, and the product goes on telling the operator the secret is overdue. The ledger is
112
+ * built here and handed to the core, which opens the row before the roll — see
113
+ * `@pithy-sh/secrets`' `cli/rotationLedger.ts`.
114
+ */
115
+ export async function runSecretRotation(
116
+ registry: SecretRegistry,
117
+ dispatcher: SecretRotationDispatcher,
118
+ command: SecretRotateCommand,
119
+ audit: CliAuditEmit = async () => {},
120
+ ): Promise<SecretRotationOutcome> {
121
+ const entry = requireEntry(registry, command.name);
122
+ // A keyspace has no single value; the core says so too, but asking the routing rule first would raise a
123
+ // less useful refusal about environments. Left to the core, which names the right thing.
124
+ const targets = entry.keyed
125
+ ? []
126
+ : secretWriteTargets({
127
+ name: command.name,
128
+ backend: entry.backend,
129
+ scope: entry.scope,
130
+ mode: "update",
131
+ requested: command.env,
132
+ declared: command.environments,
133
+ });
134
+
135
+ const outcome = await rotateSecretValue({
136
+ name: command.name,
137
+ entry,
138
+ targets,
139
+ ledger: dispatchedRotationLedger(dispatcher, { targets }),
140
+ store: ({ env, value }) =>
141
+ dispatcher.dispatch({
142
+ env,
143
+ mode: "update",
144
+ name: command.name,
145
+ value,
146
+ valueType: entry.valueType,
147
+ rotatable: entry.rotatable,
148
+ }),
149
+ ...(command.attempts !== undefined ? { attempts: command.attempts } : {}),
150
+ ...(command.sleep !== undefined ? { sleep: command.sleep } : {}),
151
+ ...(command.dryRun !== undefined ? { dryRun: command.dryRun } : {}),
152
+ });
153
+
154
+ if (outcome.status !== "unchanged") {
155
+ await audit({
156
+ action: "secrets/rotated",
157
+ outcome: outcome.status === "rotated" ? "success" : "failure",
158
+ // The unrecorded state is the one administrative act in this command that leaves a system broken,
159
+ // so it is the one that reads as `critical` in a trail somebody scans after an incident.
160
+ severity: outcome.status === "unrecorded" ? "critical" : "warning",
161
+ resourceType: "secret",
162
+ resourceId: command.name,
163
+ metadata: {
164
+ name: command.name,
165
+ environments: outcome.recorded,
166
+ rotation: outcome.kind,
167
+ rolled: outcome.rolled,
168
+ // The trail is what an incident review reads, and *was rolled* against *may have been rolled* is
169
+ // the fact that review turns on. Recorded rather than left to be inferred from the absence of
170
+ // environments, which is the same information only if somebody knows to look for it.
171
+ ...(outcome.rollFailed === undefined ? {} : { rollFailed: outcome.rollFailed }),
172
+ ...(outcome.stranded.length > 0 ? { stranded: outcome.stranded } : {}),
173
+ },
174
+ });
175
+ }
176
+ return outcome;
177
+ }
178
+
179
+ /** The issuer a `provider` or `manual` secret names, for a sentence that has to say where. */
180
+ function issuerOf(entry: SecretRegistryEntry): string {
181
+ return entry.rotation !== undefined && entry.rotation.kind !== "local" ? entry.rotation.issuer : "its issuer";
182
+ }
183
+
184
+ /** The page a `provider` or `manual` secret names, when it names one. */
185
+ function documentationOf(entry: SecretRegistryEntry): string | undefined {
186
+ return entry.rotation !== undefined && entry.rotation.kind !== "local" ? entry.rotation.documentation : undefined;
187
+ }
188
+
189
+ /** `pithy secrets update <NAME> --env <env>` — the command that records a value a human obtained. */
190
+ function updateCommand(name: string, env: ManagedEnvironment | undefined): string {
191
+ return `pithy secrets update ${name}${env === undefined ? "" : ` --env ${env}`}`;
192
+ }
193
+
194
+ /**
195
+ * The lines an operator reads, one per outcome, on stdout.
196
+ *
197
+ * **The word "rotated" appears only where a new value is stored**, and never on a run that rolled and
198
+ * lost it. Everything else is said plainly: what was replaced, where it landed, and where it did not.
199
+ */
200
+ export function rotationReportLines(
201
+ entry: SecretRegistryEntry,
202
+ outcome: SecretRotationOutcome,
203
+ env: ManagedEnvironment | undefined,
204
+ ): string[] {
205
+ const issuer = issuerOf(entry);
206
+ const documentation = documentationOf(entry);
207
+ switch (outcome.status) {
208
+ case "rotated":
209
+ return [
210
+ outcome.rolled
211
+ ? `${outcome.name} rolled at ${issuer} and recorded in ${outcome.recorded.join(", ")}.`
212
+ : `${outcome.name} rotated in ${outcome.recorded.join(", ")}.`,
213
+ ];
214
+ case "unchanged":
215
+ if (outcome.reason === "manual") {
216
+ return [
217
+ `${outcome.name} is replaced by a human at ${issuer}. Nothing was called.`,
218
+ ...(documentation === undefined ? [] : [documentation]),
219
+ `Record the new value with ${updateCommand(outcome.name, env)}.`,
220
+ ];
221
+ }
222
+ return [
223
+ outcome.kind === "provider"
224
+ ? `${outcome.name} would be rolled at ${issuer}, then written to ${outcome.stranded.join(", ")}.`
225
+ : `${outcome.name} would be minted here, then written to ${outcome.stranded.join(", ")}.`,
226
+ ];
227
+ case "unrecorded":
228
+ // **The rotator threw, so `rolled` is a guess and the line says so.** The call reached the issuer and
229
+ // the answer did not come back; nothing here can tell a request that never landed from a response
230
+ // that was lost, and claiming either is wrong half the time about the fact being acted on.
231
+ if (outcome.rollFailed) {
232
+ return [
233
+ `${outcome.name} may have been rolled at ${issuer}. Nothing was recorded.`,
234
+ `Check ${issuer} before running this again.`,
235
+ ];
236
+ }
237
+ return [
238
+ outcome.recorded.length > 0
239
+ ? `${outcome.name} rolled at ${issuer} and recorded in ${outcome.recorded.join(", ")}.`
240
+ : `${outcome.name} rolled at ${issuer} and recorded nowhere.`,
241
+ `${outcome.stranded.join(", ")} still holds a credential ${issuer} has retired.`,
242
+ ];
243
+ case "failed":
244
+ return outcome.recorded.length > 0
245
+ ? [
246
+ `${outcome.name} rotated in ${outcome.recorded.join(", ")}.`,
247
+ `${outcome.stranded.join(", ")} refused the write, and still holds the previous value.`,
248
+ ]
249
+ : [`${outcome.name} was not rotated. Nothing was rolled and nothing was written.`];
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The unrecorded state as a throwable failure: its own code, the secret and the issuer in `message`, and
255
+ * an action line that names the console and the command.
256
+ *
257
+ * **The action is the whole point of the declaration.** #322 put an issuer and a documentation URL on
258
+ * every non-`local` rotation precisely so this sentence could be written from the registry rather than
259
+ * guessed — the operator holding a dead credential should not also have to work out whose console it is.
260
+ *
261
+ * The `cause` is attached but never rendered into `message`: what the store said belongs in `detail`,
262
+ * which the HTTP codec strips, and the operator's own remedy belongs in front of them.
263
+ */
264
+ export function unrecordedFailure(
265
+ entry: SecretRegistryEntry,
266
+ outcome: SecretRotationOutcome,
267
+ env: ManagedEnvironment | undefined,
268
+ ): SecretRotationUnrecordedError {
269
+ const issuer = issuerOf(entry);
270
+ const documentation = documentationOf(entry);
271
+ const where = outcome.stranded.join(", ");
272
+ const page = documentation === undefined ? "" : ` ${documentation}`;
273
+ // The rotator never answered, so whether the credential moved is unknown — and the remedy starts with
274
+ // finding out, not with rolling again. Rolling again on an issuer that already rolled produces a second
275
+ // orphan; the check is the step that difference costs.
276
+ if (outcome.rollFailed) {
277
+ return new SecretRotationUnrecordedError(
278
+ {
279
+ message: `${outcome.name} may have been rolled at ${issuer}, and nothing was recorded. ${where} may be holding a retired credential.`,
280
+ // Careful about what it claims. The rotator threw, so whether this process ever held the value is
281
+ // itself unknown — "the value is gone" would be a second guess stacked on the first. What is known
282
+ // is that nothing recorded one, and that is what the sentence says.
283
+ action: `Check at ${issuer} whether a new credential was issued. If one was, nothing here recorded it — take the value from ${issuer} and record it with ${updateCommand(outcome.name, env)}. If none was, run this again.${page}`,
284
+ detail: `rotate '${outcome.name}': rotator failed, nothing stored in [${where}] — ${describeCause(outcome.cause)}`,
285
+ },
286
+ { cause: outcome.cause },
287
+ );
288
+ }
289
+ return new SecretRotationUnrecordedError(
290
+ {
291
+ message: `${outcome.name} was rolled at ${issuer} and its new value was not stored. ${where} holds a credential ${issuer} has retired.`,
292
+ // Said outright, because the alternative reading — that the value is sitting somewhere recoverable —
293
+ // is the one an operator will assume, and acting on it wastes the minutes that matter.
294
+ action: `The new value is gone. It existed only in this process, and printing it would leave a live credential in your shell history. Roll it again at ${issuer}, then record it with ${updateCommand(outcome.name, env)}.${page}`,
295
+ detail: `rotate '${outcome.name}': rolled, recorded in [${outcome.recorded.join(", ")}], not recorded in [${where}] after ${outcome.attempts ?? 0} store attempts — ${describeCause(outcome.cause)}`,
296
+ },
297
+ { cause: outcome.cause },
298
+ );
299
+ }
300
+
301
+ /** What ended the store attempt, for `detail` only. Never a value — the store is given one, never asked for one. */
302
+ function describeCause(cause: unknown): string {
303
+ if (cause instanceof Error) return cause.message;
304
+ return cause === undefined ? "no cause recorded" : String(cause);
305
+ }
@@ -0,0 +1,178 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import { isSecretsCapability } from "@pithy-sh/secrets/src/capability";
7
+ import { type AuditResult, auditSecrets, passesPromoteGate } from "@pithy-sh/secrets/src/cli/audit";
8
+ import {
9
+ dispatchSecretWrite,
10
+ environmentsWrittenBeforeFailure,
11
+ type SecretDispatcher,
12
+ } from "@pithy-sh/secrets/src/cli/dispatch";
13
+ import { validateSecretValue } from "@pithy-sh/secrets/src/cli/validate";
14
+ import { parseKeyedSecretName } from "@pithy-sh/secrets/src/keyspace";
15
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
16
+ import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
17
+ import type { CliAuditEmit } from "../audit/cliAudit";
18
+ import { allCapabilities, type WorkerConfig } from "../project/config";
19
+
20
+ /** The audit action for a value-touching secret command, by mode. Never carries the secret's value. */
21
+ const SECRET_WRITE_ACTION: Record<SecretWriteCommand["mode"], string> = {
22
+ create: "secrets/set",
23
+ update: "secrets/rotated",
24
+ delete: "secrets/removed",
25
+ };
26
+
27
+ /**
28
+ * Discover a Worker's secret registry by finding the secrets capability in its loaded
29
+ * `apps/<name>/pithy.config.ts` (#25's config model). The capability carries its own registry, so there
30
+ * is no separate loading convention — the CLI reads what the Worker reads. Capabilities are per-Worker,
31
+ * so the registry is too: a caller spanning several Workers resolves each and merges by secret name
32
+ * (the name is the join key).
33
+ */
34
+ export function resolveSecretRegistry(config: WorkerConfig): SecretRegistry {
35
+ const capability = allCapabilities(config).find(isSecretsCapability);
36
+ if (!capability) {
37
+ throw new NotFoundError({
38
+ message: "The secrets capability isn't enabled in this worker.",
39
+ action: "Add secrets({ registry }) to the worker's pithy.config.ts capabilities, or pass --worker.",
40
+ });
41
+ }
42
+ return capability.secretRegistry;
43
+ }
44
+
45
+ /**
46
+ * The brains of `pithy secrets` — pure wiring over the `@pithy-sh/secrets` cores, with the registry
47
+ * and dispatcher injected so it is fully testable. The citty command (`commands/secrets.ts`) handles
48
+ * I/O — value capture, registry discovery, building the live dispatcher — and calls these.
49
+ */
50
+
51
+ export interface SecretWriteCommand {
52
+ mode: "create" | "update" | "delete";
53
+ name: string;
54
+ /** The raw value for create/update (omitted for delete) — validated client-side here. */
55
+ value?: string;
56
+ /**
57
+ * The environment the operator named with `--env`, or `undefined` when they named none.
58
+ *
59
+ * **Not defaulted, and that is load-bearing.** A missing `--env` on a `global` secret used to be
60
+ * resolved to the canonical environment before this was called, which made *narrow this write to
61
+ * staging* and *say nothing* indistinguishable by the time anything could refuse either.
62
+ * `secretWriteTargets` refuses on exactly that difference, so the absence has to reach it.
63
+ */
64
+ env: ManagedEnvironment | undefined;
65
+ /**
66
+ * Every environment the project declares, from the root `pithy.config.ts` (#241) — the set a `global`
67
+ * secret fans out across, and the one the canonical CF-Secrets-Store write is chosen from.
68
+ *
69
+ * Beside `env` rather than defaulted, because a default would be the silence this replaced: a project
70
+ * declaring `live` would have its shared secrets written to staging and prod and not to `live`, and
71
+ * nothing would say so.
72
+ */
73
+ environments: DeclaredEnvironments | readonly string[];
74
+ }
75
+
76
+ /**
77
+ * Validate a value client-side (the authoritative A2 check) and dispatch the write to the manager
78
+ * Workflow(s). The registry lookup gives the routing facts (backend, scope) and the schema; an
79
+ * undeclared secret is rejected before anything is sent. Returns the environments written.
80
+ *
81
+ * Audited on success and on failure — `secrets/set` (create), `secrets/rotated` (update), or
82
+ * `secrets/removed` (delete) — recording only the secret's **name** and the environments it reached.
83
+ * The value itself, and anything derived from it, never appears in an audit event: that is the one
84
+ * hard rule of a secrets trail.
85
+ */
86
+ export async function runSecretWrite(
87
+ registry: SecretRegistry,
88
+ dispatcher: SecretDispatcher,
89
+ command: SecretWriteCommand,
90
+ audit: CliAuditEmit = async () => {},
91
+ ): Promise<ManagedEnvironment[]> {
92
+ const entry = registry[command.name];
93
+ if (!entry) {
94
+ throw new NotFoundError({
95
+ message: `Secret '${command.name}' is not declared in the registry.`,
96
+ action: "Add it to your secret registry, then run this again.",
97
+ });
98
+ }
99
+
100
+ // A keyspace has no single value to write, and a write under its bare name would land somewhere no
101
+ // member read ever looks. Its members belong to the app that mints them, which writes them in-worker.
102
+ if (entry.keyed) {
103
+ throw new ValidationError({
104
+ message: `Secret '${command.name}' is a keyspace, not a secret.`,
105
+ action: "Its members are written by the application that owns them, one key at a time.",
106
+ });
107
+ }
108
+
109
+ let value: string | undefined;
110
+ if (command.mode !== "delete") {
111
+ if (command.value === undefined || command.value === "") {
112
+ throw new ValidationError({ message: `A value is required to ${command.mode} '${command.name}'.` });
113
+ }
114
+ value = validateSecretValue(entry, command.name, command.value);
115
+ }
116
+
117
+ const action = SECRET_WRITE_ACTION[command.mode];
118
+ try {
119
+ const targets = await dispatchSecretWrite(
120
+ dispatcher,
121
+ {
122
+ mode: command.mode,
123
+ name: command.name,
124
+ backend: entry.backend,
125
+ scope: entry.scope,
126
+ rotatable: entry.rotatable,
127
+ valueType: entry.valueType,
128
+ value,
129
+ requested: command.env,
130
+ },
131
+ command.environments,
132
+ );
133
+ await audit({
134
+ action,
135
+ outcome: "success",
136
+ severity: "warning",
137
+ resourceType: "secret",
138
+ resourceId: command.name,
139
+ metadata: { name: command.name, environments: targets },
140
+ });
141
+ return targets;
142
+ } catch (error) {
143
+ // **The environments it reached before it failed, not none of them.** This recorded the name alone,
144
+ // so a `global` fan-out that half-completed left a trail saying a write failed and nothing saying
145
+ // which environments now hold the new value — for `pithy secrets rm`, which environments no longer
146
+ // hold a live key. A refusal reaches this with an empty list, which is true: nothing was sent.
147
+ await audit({
148
+ action,
149
+ outcome: "failure",
150
+ severity: "warning",
151
+ resourceType: "secret",
152
+ resourceId: command.name,
153
+ metadata: { name: command.name, environments: environmentsWrittenBeforeFailure(error) },
154
+ });
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ /** The `ls` / `ls --check` view: the declared names (keyspaces included), the audit, and the gate. */
160
+ export interface SecretsListView {
161
+ names: string[];
162
+ audit: AuditResult;
163
+ promotable: boolean;
164
+ }
165
+
166
+ export function runSecretsList(registry: SecretRegistry, presentNames: string[]): SecretsListView {
167
+ const names = Object.keys(registry).sort();
168
+ // A keyspace is expected to have no value of its own, and its stored members are expected to have no
169
+ // registry entry of their own. Counting either would make the promote gate unpassable the day an
170
+ // adopter declares their first per-tenant credential, and would report every tenant as junk.
171
+ const expected = names.filter((name) => !registry[name]?.keyed);
172
+ const present = presentNames.filter((stored) => {
173
+ const member = parseKeyedSecretName(stored);
174
+ return !(member && registry[member.name]?.keyed);
175
+ });
176
+ const audit = auditSecrets(expected, present);
177
+ return { names, audit, promotable: passesPromoteGate(audit) };
178
+ }
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareWorkflowsClient } from "@pithy-sh/cloudflare/src/workflows/workflowsClient";
5
+ import type { SecretDispatcher, SecretProbe, SecretRotationRecorder } from "@pithy-sh/secrets/src/cli/dispatch";
6
+ import { WorkflowSecretDispatcher } from "@pithy-sh/secrets/src/manager/dispatcher";
7
+
8
+ /**
9
+ * Build the live secrets dispatcher every value-touching command writes through — the manager
10
+ * write-Workflow over the CF Workflows REST API, keyed by the canonical `<project>-<env>-secrets-write`
11
+ * Workflow name. Shared by `pithy secrets`, `pithy turnstile`, `pithy storage`, and `pithy media` so the
12
+ * dispatch wiring lives in exactly one place.
13
+ *
14
+ * `project` is the root `pithy.config.ts` `name` via `requireProjectName`, never a guess. Workflow names
15
+ * are account-scoped: a wrong project here dispatches this project's secret values into another
16
+ * project's manager, which encrypts and stores them under a master key this project cannot read.
17
+ *
18
+ * It is also the {@link SecretProbe} — the read seam provisioning asks before it mints anything — and the
19
+ * {@link SecretRotationRecorder}, the rotation ledger `pithy secrets rotate` opens a row in before it rolls
20
+ * (`#379`). All three contracts land on the same Workflow, so the same one object answers them, and a
21
+ * caller cannot end up probing or recording against one project's manager while writing to another's.
22
+ */
23
+ export function buildSecretDispatcher(
24
+ accountId: string,
25
+ apiToken: string,
26
+ project: string,
27
+ ): SecretDispatcher & SecretProbe & SecretRotationRecorder {
28
+ return new WorkflowSecretDispatcher(new CloudflareWorkflowsClient({ accountId, apiToken }), project);
29
+ }