@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,246 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { KitErrorPayload } from "@pithy-sh/core/src/error/payload";
5
+ import type { ArgsDef, CommandDef } from "citty";
6
+ import { CATALOG } from "../capabilities/catalog";
7
+ import { HIDDEN_ROOT_FLAGS } from "../commands/alias";
8
+ import { main } from "../main";
9
+ import { ROOT_FLAGS } from "../rootFlags";
10
+
11
+ /**
12
+ * What the kit contains, as one file another repository can read.
13
+ *
14
+ * `pithy.sh/docs` is 360 pages of hand-written reference and nothing verifies it. The site's own harness
15
+ * (`marketing/scripts/check/run.mjs`) is where that verification goes — three questions, each answerable
16
+ * only from here: does every `pithy add <name>` on a page name a real capability, does every flag a page
17
+ * cites exist in that command's parser, and does every error code a page quotes exist in the kit.
18
+ *
19
+ * **The shape of the answer is a decision for this repository, and this is it.** The site's slug check
20
+ * already asks the first question, and it asks it by reading `capabilities/catalog.ts` off an absolute
21
+ * path and running a regular expression over the TypeScript. That has failed the way regular expressions
22
+ * over source fail: the character class was `[a-z-]`, `i18n` has a digit in it, and the catalog entry
23
+ * simply did not match — so `pithy add i18n` read as an unknown slug on every page that stated it. The
24
+ * check carries a `names.length < 10` tripwire because a partial read is otherwise indistinguishable
25
+ * from a complete one.
26
+ *
27
+ * So the export is generated from the values themselves — the catalog array, the composed citty tree,
28
+ * the error union — and committed as strict JSON. A consumer parses it; there is no shape to guess at
29
+ * and no partial read to detect.
30
+ *
31
+ * **JSON, not JSONC**, which is the one place this departs from the repository's config rule: the reader
32
+ * is another repository's `JSON.parse`, and that tool does not recognize comments. The rationale lives
33
+ * here, in the generator, which is where `docs/catalog.generated.json` sends anyone who opens it.
34
+ */
35
+
36
+ /** Where the generated file lives, relative to the repository root. One string, so nothing spells it twice. */
37
+ export const CATALOG_PATH = "docs/catalog.generated.json";
38
+
39
+ /** The note the file opens with. See {@link DocsCatalog.$generated} for why it is a field and not a comment. */
40
+ const GENERATED_NOTE =
41
+ "Generated by packages/cli/src/docs/writeCatalog.ts. Do not edit by hand; run `bun run docs-catalog`. " +
42
+ "Strict JSON rather than JSONC because the reader is another repository's JSON.parse — see " +
43
+ "packages/cli/src/docs/catalog.ts for what each section answers.";
44
+
45
+ /** One capability, as `pithy add` accepts it. */
46
+ export interface CatalogCapability {
47
+ /** The `pithy add <name>` argument. */
48
+ name: string;
49
+ /** The npm package it ships in — `@pithy-sh/<name>` for all but `controlplane`, which ships inside core. */
50
+ package: string;
51
+ }
52
+
53
+ /** One command in the tree: the path a caller types, and the flags its parser accepts. */
54
+ export interface CatalogCommand {
55
+ /** Space-separated, from the root down — `add`, `token mint`. The root itself is not a command. */
56
+ path: string;
57
+ /** Every flag spelling the parser answers to: the long form, then any aliases. */
58
+ flags: string[];
59
+ }
60
+
61
+ /** One error code the kit defines, and the HTTP status its member pins. */
62
+ export interface CatalogErrorCode {
63
+ /** `domain/reason`, namespaced per capability. */
64
+ code: string;
65
+ /** The status the kit pins to this code. Every kit member pins exactly one. */
66
+ status: number;
67
+ }
68
+
69
+ /**
70
+ * Everything a docs check needs to know about the kit.
71
+ *
72
+ * **`commands` answers whether a flag exists on a command, not whether a page may name it.** A page
73
+ * legitimately cites another command's flag — `docs/commands/deploy.md` writes `pithy provision --env
74
+ * <name> --yes` while explaining what deploy refuses to do — and prose quotes flags belonging to
75
+ * neither (`git rev-parse --git-common-dir`). So a consumer matches `<command> --flag` as a pair.
76
+ * Attributing every flag on a page to that page's command reports correct pages as wrong, which is
77
+ * worse than no check at all: one that cries wolf gets turned off.
78
+ */
79
+ export interface DocsCatalog {
80
+ /**
81
+ * What this file is, for whoever opens it.
82
+ *
83
+ * A generated file says so at the top, and every other one this repository generates says it in a
84
+ * comment. This one cannot: strict JSON has no comments, and the reason it is strict JSON is the
85
+ * reader. So the note is a field. It is first because that is where a reader looks, and a consumer
86
+ * that reads the sections by name never sees it.
87
+ */
88
+ $generated: string;
89
+ capabilities: CatalogCapability[];
90
+ commands: CatalogCommand[];
91
+ /** Flags that work on any command, declared by no command — `bin.ts` answers them before citty parses. */
92
+ globalFlags: string[];
93
+ errorCodes: CatalogErrorCode[];
94
+ }
95
+
96
+ /**
97
+ * Every flag the CLI parses outside a command's `args`, in every spelling.
98
+ *
99
+ * A walk of the command tree cannot find one of these: `bin.ts` answers all six before citty is handed
100
+ * the arguments. So `pithy add --help` and `pithy --pithiest` both cite something real, and a check
101
+ * reading only the parsers would call each a typo — on a page that is correct.
102
+ *
103
+ * **Composed from the modules that decide, never restated.** The two hidden flags were missed on the
104
+ * first pass, and a literal list here would go stale the same way the moment a seventh landed: hidden
105
+ * from `--help` is not hidden from a docs check, and nothing would have said so. `ROOT_FLAGS` and
106
+ * `HIDDEN_ROOT_FLAGS` are exported for this, so a new out-of-band flag reaches the export with nothing
107
+ * to remember.
108
+ */
109
+ const GLOBAL_FLAGS: readonly string[] = [...ROOT_FLAGS, ...HIDDEN_ROOT_FLAGS];
110
+
111
+ /** One arg's declared aliases, in the spelling a caller types: a single letter takes one dash, a word takes two. */
112
+ function aliasFlags(alias: unknown): string[] {
113
+ const names = typeof alias === "string" ? [alias] : Array.isArray(alias) ? alias : [];
114
+ return names
115
+ .filter((name): name is string => typeof name === "string")
116
+ .map((name) => (name.length === 1 ? `-${name}` : `--${name}`));
117
+ }
118
+
119
+ /**
120
+ * The camelCase spelling of a kebab-case arg name, or the name unchanged when it has no dash.
121
+ *
122
+ * citty registers `camelCase(name)` and `kebabCase(name)` as aliases of **every** arg it parses, so
123
+ * `--withPrerequisites` reaches the same value as `--with-prerequisites`. This transform is narrow on
124
+ * purpose — it handles lowercase kebab and nothing else — and `catalog.test.ts` holds every arg name in
125
+ * the CLI to that shape, so the narrow version is complete rather than merely convenient. The kebab
126
+ * direction is a no-op over that domain, which is why only this one exists.
127
+ */
128
+ function camelSpelling(name: string): string {
129
+ return name.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase());
130
+ }
131
+
132
+ /**
133
+ * Every flag one command's parser answers to.
134
+ *
135
+ * Three spellings beyond the declared name, each of them citty's rather than ours, and each one a false
136
+ * failure for a docs check that does not know about it:
137
+ *
138
+ * - **Declared aliases**, long and short.
139
+ * - **The camelCase form.** citty aliases every arg to its camel and kebab spellings, so
140
+ * `--withPrerequisites` works. The `flagsOf` doc used to claim citty does no case mapping. It does.
141
+ * - **`--no-<name>` on a boolean.** citty strips a `--no-` prefix from any argument before parsing, and
142
+ * this CLI documents the result: `ui.ts`'s own description offers `--no-auth for the bare SPA`, and
143
+ * `docs/commands/ui.md` puts `[--auth | --no-auth]` in its synopsis. An export without it makes a
144
+ * deliberately-documented page read as citing a flag that does not exist — the cries-wolf failure this
145
+ * whole shape was chosen to avoid. Emitted for booleans only: citty would also answer `--no-env`, but
146
+ * naming that would be claiming a flag rather than reporting one.
147
+ *
148
+ * A **positional** is not a flag and is left out: it carries no `--`, so a check looking for one would
149
+ * never ask about it.
150
+ */
151
+ export function flagsOf(args: ArgsDef | undefined): string[] {
152
+ const flags: string[] = [];
153
+ for (const [name, def] of Object.entries(args ?? {})) {
154
+ const arg = def as { type?: string; alias?: unknown };
155
+ if (arg.type === "positional") continue;
156
+ flags.push(`--${name}`);
157
+ const camel = camelSpelling(name);
158
+ if (camel !== name) flags.push(`--${camel}`);
159
+ flags.push(...aliasFlags(arg.alias));
160
+ if (arg.type === "boolean") flags.push(`--no-${name}`);
161
+ }
162
+ return flags;
163
+ }
164
+
165
+ /**
166
+ * citty's `Resolvable<T>`: a value, a promise of one, or a thunk returning either.
167
+ *
168
+ * Both `subCommands` and `args` are declared this way, so one resolver covers both. Reading either
169
+ * unawaited is silent rather than loud — `Object.entries` of a promise or a function is `[]`, so the
170
+ * command reads as taking no flags and the group as having no children.
171
+ */
172
+ type Resolvable<T> = T | Promise<T> | (() => T | Promise<T>);
173
+
174
+ /** One `Resolvable`, resolved. */
175
+ async function resolve<T>(value: Resolvable<T>): Promise<T> {
176
+ return typeof value === "function" ? await (value as () => T | Promise<T>)() : await value;
177
+ }
178
+
179
+ /**
180
+ * Every command under `root`, sorted by the path a caller types.
181
+ *
182
+ * Groups are walked **and listed**. `pithy token` takes no flags of its own, but a page that spells one
183
+ * is still citing a command, and a list that skipped it would have nothing to say about that page.
184
+ *
185
+ * All three of citty's subcommand spellings are resolved. This CLI writes thunks so the tree stays lazy;
186
+ * reading only the literal form would have found the root's twenty-six names and none of their flags.
187
+ */
188
+ export async function walkCommands(root: CommandDef, prefix: string[] = []): Promise<CatalogCommand[]> {
189
+ const found: CatalogCommand[] = [];
190
+ // The container is a `Resolvable` as much as anything inside it, and this repository already builds the
191
+ // lazy form: `dispatch.ts`'s `ownNamesOnly` returns `subCommands: async () => …`, and `bin.ts` wraps the
192
+ // tree in it before dispatching. Reading it unawaited takes `Object.entries` of a function — no names,
193
+ // no children, and a whole subtree missing from the export with nothing to say so, because the
194
+ // non-empty guard is still satisfied by the commands that did resolve.
195
+ const declared = root.subCommands === undefined ? {} : await resolve(root.subCommands);
196
+ const subCommands = declared as Record<string, Resolvable<CommandDef>>;
197
+ for (const [name, value] of Object.entries(subCommands)) {
198
+ const child = await resolve(value);
199
+ const path = [...prefix, name];
200
+ found.push({
201
+ path: path.join(" "),
202
+ flags: flagsOf(child.args === undefined ? undefined : await resolve(child.args)),
203
+ });
204
+ found.push(...(await walkCommands(child, path)));
205
+ }
206
+ return found.sort((left, right) => left.path.localeCompare(right.path));
207
+ }
208
+
209
+ /**
210
+ * The catalog, built from the values the kit runs on.
211
+ *
212
+ * Every section is asserted non-empty. Not a floor — a floor is a number somebody has to raise by hand,
213
+ * and it is wrong the moment they do not — but the one state that can only mean the reader broke: a kit
214
+ * with no capabilities, no commands or no error codes has never existed. A check running against an
215
+ * empty section reports `ok` on every page, and that is the result worth refusing to write.
216
+ */
217
+ export async function buildDocsCatalog(): Promise<DocsCatalog> {
218
+ const catalog: DocsCatalog = {
219
+ $generated: GENERATED_NOTE,
220
+ capabilities: CATALOG.map((entry) => ({ name: entry.name, package: entry.package })).sort((left, right) =>
221
+ left.name.localeCompare(right.name),
222
+ ),
223
+ commands: await walkCommands(main),
224
+ globalFlags: [...GLOBAL_FLAGS].sort(),
225
+ errorCodes: KitErrorPayload.options
226
+ .map((member) => ({ code: member.shape.code.value, status: member.shape.status.value }))
227
+ .sort((left, right) => left.code.localeCompare(right.code)),
228
+ };
229
+ for (const [section, entries] of Object.entries(catalog)) {
230
+ if (Array.isArray(entries) && entries.length === 0) {
231
+ throw new Error(`The docs catalog read no ${section}. Fix the reader before trusting it.`);
232
+ }
233
+ }
234
+ return catalog;
235
+ }
236
+
237
+ /**
238
+ * The exact bytes the catalog file should hold: two-space JSON, one trailing newline.
239
+ *
240
+ * `.generated.` is in the filename because Biome ignores that, and it has to: this expands every array
241
+ * and Biome's JSON formatter fits short ones onto one line, so the writer and the formatter would each
242
+ * undo the other on every run. See `biome.jsonc`.
243
+ */
244
+ export function renderDocsCatalog(catalog: DocsCatalog): string {
245
+ return `${JSON.stringify(catalog, null, 2)}\n`;
246
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Write `docs/catalog.generated.json` — what the kit contains, for the docs site's own check.
6
+ *
7
+ * See `./catalog.ts` for why the export exists and why it is strict JSON. This is the writer, and it is
8
+ * the same shape as `scripts/stampVersions.ts` at the repository root, for the same reason: the file is
9
+ * **committed**, so a consumer that is neither this repository nor an installer of it can read it with
10
+ * no build step — and a committed generated file is exactly the kind that goes stale quietly.
11
+ *
12
+ * `--check` fails instead of writing, and runs in CI's whole-repo verify job beside the stamped
13
+ * versions. Drift here is a docs check reporting `ok` against a kit that has moved: every page passes,
14
+ * and the pages are wrong.
15
+ *
16
+ * **It lives under `src/`, not `scripts/`, and that is forced.** `scripts/tsconfig.json` is a Node-only
17
+ * program — `types: ["node"]`, no Workers types — because nothing it holds may drag the CLI's graph in
18
+ * behind it. Building the catalog means composing the real command tree, which reaches every capability
19
+ * package and every `CryptoKey` and `HTMLRewriter` in them. So the entry script belongs in the program
20
+ * that already has those types, exactly as `bin.ts` does.
21
+ */
22
+ import { writeFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { readOptionalFile } from "../project/readOptionalFile";
26
+ import { buildDocsCatalog, CATALOG_PATH, renderDocsCatalog } from "./catalog";
27
+
28
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
29
+ const path = join(REPO_ROOT, CATALOG_PATH);
30
+ const expected = renderDocsCatalog(await buildDocsCatalog());
31
+
32
+ // `readOptionalFile`, not a swallowed `readFileSync`: absent means "write it", and every other failure
33
+ // has to be loud. A discarded permission error would make `--check` report drift it could not see and
34
+ // a plain run overwrite a file it never read.
35
+ const actual = await readOptionalFile(path);
36
+
37
+ if (actual === expected) {
38
+ process.stdout.write(`${CATALOG_PATH} is current.\n`);
39
+ } else if (process.argv.includes("--check")) {
40
+ process.stderr.write(`${CATALOG_PATH} is stale. Run \`bun run docs-catalog\`.\n`);
41
+ process.exit(1);
42
+ } else {
43
+ writeFileSync(path, expected);
44
+ process.stdout.write(`Wrote ${CATALOG_PATH}.\n`);
45
+ }
@@ -0,0 +1,287 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import { CLOUDFLARE_CREDENTIAL_KEYS } from "@pithy-sh/cloudflare/src/env/devVars";
6
+ import {
7
+ type CloudflareAccountMismatch,
8
+ type CloudflareConfigOptions,
9
+ type CloudflareCredentialSource,
10
+ type CloudflareCredentialSplit,
11
+ cloudflareCredentialSplit,
12
+ describeCloudflareAccountMismatch,
13
+ PITHY_OFFLINE_ENV,
14
+ resolveCloudflare,
15
+ } from "../cloudflare/config";
16
+
17
+ /**
18
+ * Whether `pithy doctor` can reach Cloudflare with the credentials the project is configured with.
19
+ *
20
+ * **Why this is a health check and not a note.** The bootstrap `CLOUDFLARE_API_TOKEN` +
21
+ * `CLOUDFLARE_ACCOUNT_ID` pair (`docs/TOKENS.md`) is the first thing anyone sets up on a new account and
22
+ * the first thing they get wrong — and every failure downstream is a command that was going to work.
23
+ * Nothing else reported a bad credential until the command that needed it failed mid-run, which is a
24
+ * worse place to learn it.
25
+ *
26
+ * The two checks are deliberately separate because they fail for different reasons and want different
27
+ * fixes. A live token scoped to the *wrong account* passes verification and fails everything after it —
28
+ * the exact mistake available the moment somebody has a second Cloudflare account.
29
+ */
30
+ export type CloudflareAccessState =
31
+ /** Neither credential is set. Legitimate before provisioning; never fails the exit. */
32
+ | "unconfigured"
33
+ /** The token verified and the account answered. */
34
+ | "ok"
35
+ /** `GET /user/tokens/verify` rejected it — wrong value, revoked, or expired. */
36
+ | "token_invalid"
37
+ /** The token is live but this account did not answer — usually the wrong account id, or missing permissions. */
38
+ | "account_unreachable"
39
+ /**
40
+ * The project pins `cloudflare.accountId` and the resolved credentials belong to a different account.
41
+ *
42
+ * Decided before anything reaches the network, and it stays that way: the one thing worse than
43
+ * reporting the wrong account would be authenticating as it first. It fails the exit like every other
44
+ * non-`ok` state, which is the whole guarantee the pin buys (#206).
45
+ */
46
+ | "account_mismatch"
47
+ /**
48
+ * Nothing was asked of Cloudflare, because the caller said not to — `PITHY_OFFLINE`, or `--offline` (#218).
49
+ *
50
+ * **A fifth state rather than reusing `unconfigured`**, even though offline in a scratch config
51
+ * directory resolves exactly nothing. `unconfigured`'s sentence tells you to set the missing keys "in
52
+ * `<config>/cloudflare.json`, or the environment" — and the environment is the half this mode is
53
+ * refusing, so the advice would be for a thing that would not work. Silence is worse still: a
54
+ * diagnostic that skipped a check and printed nothing has reported a health it never established.
55
+ *
56
+ * It never fails the exit, for the reason `unconfigured` does not: nothing was established, and only a
57
+ * fault positively established may gate CI. An adopter on a plane, and a sandbox with no business
58
+ * touching an account, get the same green exit and the same honest line.
59
+ */
60
+ | "not_checked"
61
+ /**
62
+ * The probe itself threw — most often a credentials file that will not parse (#371).
63
+ *
64
+ * **Distinct from `not_checked`, which is the caller having said not to look.** One is a decision and
65
+ * the other is a failure, and a diagnostic that reports "offline" about a broken config file has sent
66
+ * the reader to unset an environment variable that was never set. It never fails the exit, on the rule
67
+ * every other establishes-nothing state here follows.
68
+ */
69
+ | "probe_failed";
70
+
71
+ /** What `doctor` learned about the configured Cloudflare credentials. */
72
+ export interface CloudflareAccess {
73
+ state: CloudflareAccessState;
74
+ /** Which credential keys were absent, so a half-configured setup names the missing half rather than both. */
75
+ missing: string[];
76
+ /** The token's lifecycle status from `GET /user/tokens/verify` (`active`), or null when it could not be read. */
77
+ tokenStatus: string | null;
78
+ /**
79
+ * Set when the account id and the token came from **different sources** — part of the pair from
80
+ * `<config>/cloudflare.json`, the rest overlaid from the ambient environment.
81
+ *
82
+ * Reported alongside the state rather than as one, because it is orthogonal to reachability: mixed
83
+ * credentials may well reach *an* account. It never fails the exit — the pair may be valid, and only a
84
+ * fault this project's own config positively establishes may gate CI.
85
+ */
86
+ credentialSplit: CloudflareCredentialSplit | null;
87
+ /**
88
+ * The credentials file this run resolved — `<config>/cloudflare.json`, or the account-named file the
89
+ * project selected — named whether or not it exists.
90
+ *
91
+ * **Optional, and every field below it is, because they are facts about *this machine's* resolution
92
+ * rather than about reachability.** A caller that supplies its own probe has no file to name, and
93
+ * `describeCloudflareAccess` prints exactly what it printed before when nothing names one.
94
+ */
95
+ configPath?: string;
96
+ /** The project's `cloudflare.accountName`, or `null` when it named none. */
97
+ accountName?: string | null;
98
+ /** The pin disagreeing with the resolved credentials. Set exactly when the state is `account_mismatch`. */
99
+ accountMismatch?: CloudflareAccountMismatch | null;
100
+ /**
101
+ * Which source supplied the pair — `file`, `environment`, `mixed`, or `null` when neither key resolved.
102
+ *
103
+ * The third acceptance criterion of #218, and the one that would have caught the incident on sight: the
104
+ * report named `~/.config/pithy/cloudflare.json` while the credentials came from a shell nobody in that
105
+ * session had looked at, and there was no line anywhere that distinguished the two.
106
+ */
107
+ credentialSource?: CloudflareCredentialSource | null;
108
+ }
109
+
110
+ /**
111
+ * The credential keys this check needs, in the order they are reported — the same group
112
+ * {@link cloudflareCredentialSplit} watches, because "both must be set" and "both must come from one
113
+ * account" are two readings of one pair.
114
+ */
115
+ const REQUIRED_KEYS = CLOUDFLARE_CREDENTIAL_KEYS;
116
+
117
+ /**
118
+ * Verify the token against the endpoint that matches its kind.
119
+ *
120
+ * **Cloudflare has two, and using the wrong one rejects a working credential.** `/user/tokens/verify`
121
+ * answers only for user-bound (`cfut_*`) tokens; an account-owned (`cfat_*`) one gets `Invalid API Token`.
122
+ * `CLAUDE.md` prefers account-owned tokens, so the user endpoint is wrong for the *common* case — a
123
+ * diagnostic built on it would confidently reject the setup it was written to validate.
124
+ *
125
+ * The prefix is the only part of the token read, matching `@pithy-sh/audit`'s actor resolution. An
126
+ * unrecognised prefix falls back to trying both rather than guessing, since a wrong "invalid" here is
127
+ * worse than a slower answer.
128
+ */
129
+ async function verifyByKind(clients: CloudflareClients, apiToken: string): Promise<string | null> {
130
+ const account = () => clients.accountTokens().verifyToken();
131
+ const user = () => clients.user().verifyToken();
132
+ const order = apiToken.startsWith("cfat_") ? [account] : apiToken.startsWith("cfut_") ? [user] : [account, user];
133
+
134
+ for (const attempt of order) {
135
+ try {
136
+ return (await attempt()).status;
137
+ } catch {
138
+ // Try the next shape, or fall through to "rejected".
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Probe the configured Cloudflare credentials, reading `<config>/cloudflare.json` with the `process.env`
146
+ * overlay {@link cloudflareEnv} applies — so this reports the same credentials every other command
147
+ * resolves, in CI as well as locally.
148
+ *
149
+ * **It takes no project directory, and that is the finding rather than a simplification.** The
150
+ * credentials are account-scoped (#182): one account holds many projects, and "can I reach Cloudflare"
151
+ * has the same answer in every checkout on this machine. Passing a project root implied otherwise, and
152
+ * the file it named was inside one.
153
+ *
154
+ * Never throws: a diagnostic command has to keep working in exactly the broken environment it exists to
155
+ * diagnose, so every failure becomes a state rather than an exception.
156
+ */
157
+ export async function checkCloudflareAccess(options: CloudflareConfigOptions): Promise<CloudflareAccess> {
158
+ // `resolveCloudflare` rather than `cloudflareEnv`: a pinned mismatch is a throw everywhere else, and a
159
+ // diagnostic that died of the fault it exists to report would leave nothing to read it in.
160
+ const resolution = resolveCloudflare(options);
161
+ const vars = resolution.vars;
162
+ // Resolved from the same file and the same environment, so what is reported is a fact about *this*
163
+ // resolution rather than about a second one taken a moment later.
164
+ const credentialSplit = cloudflareCredentialSplit(options);
165
+ const where = {
166
+ configPath: resolution.path,
167
+ accountName: resolution.accountName,
168
+ accountMismatch: resolution.mismatch,
169
+ credentialSource: resolution.credentialSource,
170
+ };
171
+
172
+ // Before the credentials are even counted, and long before any of them are used: credentials for an
173
+ // account this project does not claim must not reach Cloudflare, not even to be verified.
174
+ if (resolution.mismatch)
175
+ return { state: "account_mismatch", missing: [], tokenStatus: null, credentialSplit, ...where };
176
+
177
+ const missing = REQUIRED_KEYS.filter((key) => !vars[key]);
178
+
179
+ // Offline, and this is the whole of doctor's honoring of it: no probe, and a state that says so.
180
+ //
181
+ // **After the mismatch and not before it.** That fault is decided from this machine's own files — a
182
+ // config's pin against a file's account id — so it costs no network and no ambient credential, and
183
+ // suppressing it would mean the mode hid a real fault as well as a real account. Everything below this
184
+ // line is the part that reaches Cloudflare, and none of it runs.
185
+ if (resolution.offline)
186
+ return { state: "not_checked", missing: [...missing], tokenStatus: null, credentialSplit, ...where };
187
+
188
+ if (missing.length > 0)
189
+ return { state: "unconfigured", missing: [...missing], tokenStatus: null, credentialSplit, ...where };
190
+
191
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
192
+ const clients = new CloudflareClients({ accountId: vars.CLOUDFLARE_ACCOUNT_ID ?? "", apiToken });
193
+
194
+ const tokenStatus = await verifyByKind(clients, apiToken);
195
+ if (tokenStatus === null)
196
+ return { state: "token_invalid", missing: [], tokenStatus: null, credentialSplit, ...where };
197
+
198
+ // Account-scoped, read-only, and never throws — and it exercises the one permission the bootstrap token
199
+ // must hold to mint anything (`docs/TOKENS.md`), so a token that cannot mint is caught here rather than
200
+ // by `pithy token mint`.
201
+ const reachable = await clients.accountTokens().validateServiceAccess();
202
+ if (!reachable) return { state: "account_unreachable", missing: [], tokenStatus, credentialSplit, ...where };
203
+
204
+ return { state: "ok", missing: [], tokenStatus, credentialSplit, ...where };
205
+ }
206
+
207
+ /**
208
+ * The credentials file, abbreviated against the operator's home the way every other path in the report
209
+ * is. `~/.config/pithy/cloudflare.json` when nothing named one — a stubbed probe, and the default.
210
+ */
211
+ function configPath(access: CloudflareAccess, home: string | undefined): string {
212
+ const path = access.configPath ?? "~/.config/pithy/cloudflare.json";
213
+ return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
214
+ }
215
+
216
+ /** The state's own line, before the split warning {@link describeCloudflareAccess} may append. */
217
+ function describeState(access: CloudflareAccess, home?: string): string {
218
+ switch (access.state) {
219
+ case "ok":
220
+ return `reachable (token ${access.tokenStatus ?? "verified"})`;
221
+ case "unconfigured":
222
+ // The file this run actually resolved, when it knows it. `~/.config/pithy/cloudflare.json` is the
223
+ // right answer for a single-account machine and the wrong one for a project that names an account,
224
+ // and telling somebody to set a key in a file nothing will read is worse than saying nothing.
225
+ return `not configured (set ${access.missing.join(" and ")} in ${configPath(access, home)}, or the environment)`;
226
+ case "token_invalid":
227
+ return "CLOUDFLARE_API_TOKEN rejected — check the value, or mint a new bootstrap token";
228
+ case "account_unreachable":
229
+ return "token is valid but the account did not answer — check CLOUDFLARE_ACCOUNT_ID and the token's permissions";
230
+ case "account_mismatch":
231
+ return access.accountMismatch
232
+ ? `${describeCloudflareAccountMismatch(access.accountMismatch)} Nothing will run against it.`
233
+ : "the credentials belong to an account this project does not claim";
234
+ case "not_checked":
235
+ // **Both levers, and neither claimed.** The first wording said "`PITHY_OFFLINE` is set", which is
236
+ // false on every `--offline` run — a line about not checking that is itself wrong is the exact
237
+ // failure this state exists to avoid. Naming both is not hedging: the reader of this line is as
238
+ // likely to be somebody who did not know the mode was on — a harness, a CI job, a shell profile —
239
+ // as the person who asked for it, and they need to know what to go and unset.
240
+ return `not checked — offline (${PITHY_OFFLINE_ENV} or --offline)`;
241
+ case "probe_failed":
242
+ // No reason from the throw: a credentials-resolution failure names paths, account ids and, in the
243
+ // worst case, a value. The file it would have read is the actionable fact and the suffix says it.
244
+ return "couldn't be checked — the credentials would not resolve";
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The suffix naming where the credentials stand — the file, or the environment that supplied them.
250
+ *
251
+ * `; from <file>` was the whole of this and it was a claim about the *resolution*, not about the
252
+ * credentials: CI resolves a path with nothing at it and authenticates from the environment, and so did
253
+ * the sandbox run that reached a live account off a forgotten export (#218). So the environment case says
254
+ * the environment, and names the file it did **not** read, because that file is what the reader was about
255
+ * to go and check.
256
+ *
257
+ * `mixed` keeps the file wording: the split sentence in front of it has already named which key came from
258
+ * where, in more detail than this can, and repeating it would be the line saying one thing twice.
259
+ */
260
+ function credentialOrigin(access: CloudflareAccess, home: string | undefined): string {
261
+ const path = configPath(access, home);
262
+ if (access.state === "not_checked" || access.state === "probe_failed")
263
+ return `credentials would resolve from ${path}`;
264
+ if (access.credentialSource === "environment") return `credentials from the environment, not ${path}`;
265
+ return `from ${path}`;
266
+ }
267
+
268
+ /**
269
+ * The one-line report for {@link renderDoctorText}, and the `action` a failing state should prompt.
270
+ *
271
+ * A split group is appended rather than substituted: both facts are true at once, and a reachable state
272
+ * is exactly what makes the split easy to miss — mixed credentials still reach *an* account.
273
+ */
274
+ export function describeCloudflareAccess(access: CloudflareAccess, home?: string): string {
275
+ const state = describeState(access, home);
276
+ const split = access.credentialSplit;
277
+ const withSplit = split
278
+ ? `${state}; credentials come from two places — cloudflare.json sets ${split.fromFile.join(" and ")}, the environment supplies ${split.fromEnvironment.join(" and ")} — set the whole pair in one of them`
279
+ : state;
280
+ // The file, on every run that knows one. "Which account am I about to deploy to" must never require
281
+ // inspection, and once a machine holds `cloudflare.leed.json` beside `cloudflare.other-co.json` the
282
+ // state line alone does not answer it. The two states that have already named it — a mismatch names
283
+ // both ids, an unconfigured one names where the missing keys go — are not made to say it twice.
284
+ const named = access.state === "account_mismatch" || access.state === "unconfigured";
285
+ if (!access.configPath || named) return withSplit;
286
+ return `${withSplit}; ${credentialOrigin(access, home)}`;
287
+ }