@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.
- package/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- 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
|
+
}
|