@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,685 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CLOUDFLARE_CREDENTIAL_KEYS, CLOUDFLARE_ENV_KEYS } from "@pithy-sh/cloudflare/src/env/devVars";
|
|
7
|
+
import { statesNoVanishingKey } from "@pithy-sh/core/src/capability/vanishingKey";
|
|
8
|
+
import { ConflictError, fromZodError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { ensureOwnerOnlyDirFor, tightenMode } from "../devSecrets/mode";
|
|
11
|
+
import { type StatePathOptions, stateDir } from "../notifier/state";
|
|
12
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
13
|
+
import { readOptionalFile } from "../project/readOptionalFile";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The CLI's Cloudflare credentials: `<config>/cloudflare.json`, **account-scoped**, `0600`, in the
|
|
17
|
+
* `0700` config directory (#182).
|
|
18
|
+
*
|
|
19
|
+
* **Account-scoped, not per project.** `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN` and
|
|
20
|
+
* `SECRETS_STORE_ID` are functions of the *account*: one account holds many pithy projects, Cloudflare
|
|
21
|
+
* permits one Secrets Store per account, and a per-project home would store one copy of the same token
|
|
22
|
+
* per project and make rotation an N-place edit. It is also the only scope `pithy init` can write at —
|
|
23
|
+
* the token is needed to list zones *before* there is a project name to key a directory on, which is
|
|
24
|
+
* exactly the case `bootstrapVarsPath` answers `null` for.
|
|
25
|
+
*
|
|
26
|
+
* **These left the checkout, and nothing minted goes back into it.** They used to be read from the
|
|
27
|
+
* project root's `.dev.vars`, and `pithy token mint --env production --store dev-vars` wrote a live
|
|
28
|
+
* production credential into `.dev.vars.production` beside it. Gitignored is not sufficient: #145 was an
|
|
29
|
+
* `npm pack` leak, and `npm pack` does not consult `.gitignore` when `files` is set. So no command
|
|
30
|
+
* writes a credential inside the project any more, for any environment — see `../tokens/sinks`.
|
|
31
|
+
*
|
|
32
|
+
* **`CLOUDFLARE_API_TOKEN` is not a registry secret and is not in `secrets.jsonc`.** That file holds
|
|
33
|
+
* secrets a *Worker* reads; this is a CLI credential that is never a Worker binding. `pithy doctor`
|
|
34
|
+
* already treats the two as different categories, and this file is what makes that true on disk.
|
|
35
|
+
*
|
|
36
|
+
* **The store id is written once and read from a file forever after.** Resolving it from the account on
|
|
37
|
+
* each run was considered and rejected: avoiding an API call per invocation would mean caching the id in
|
|
38
|
+
* a file, which is this key again with a staleness question and a network failure mode added. `pithy
|
|
39
|
+
* init` writes the pair at the one moment the operator is holding them; `pithy add secrets` appends the
|
|
40
|
+
* store id at the moment it provisions one. A CLI that has to ask Cloudflare where its own store is
|
|
41
|
+
* cannot run offline, which costs more than the key was costing.
|
|
42
|
+
*
|
|
43
|
+
* **`process.env` still overlays, per key.** CI supplies these as real environment variables and has no
|
|
44
|
+
* file at all — see {@link cloudflareEnv}. {@link PITHY_OFFLINE_ENV} is the one way to turn that off.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** The file's name inside the Pithy config directory. Undotted: nothing here is hidden from anything. */
|
|
48
|
+
export const CLOUDFLARE_CONFIG_FILE_NAME = "cloudflare.json";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The variable that says **no ambient credentials, and nothing over the wire** (#218).
|
|
52
|
+
*
|
|
53
|
+
* `PITHY_CONFIG_DIR` relocates the *file*. It has never touched the `process.env` overlay, and that
|
|
54
|
+
* overlay is correct — CI supplies the pair as environment variables and has no file at all (#182). The
|
|
55
|
+
* consequence was that redirecting the config directory *looked* like isolation and was not: `pithy
|
|
56
|
+
* doctor` in an empty scratch directory reached the operator's real account, twice, off a token their
|
|
57
|
+
* shell had exported hours earlier, and reported `reachable (token active)` about an account nobody in
|
|
58
|
+
* that session had named.
|
|
59
|
+
*
|
|
60
|
+
* **Making `PITHY_CONFIG_DIR` imply this was considered and rejected.** It conflates two different
|
|
61
|
+
* sentences — "read config from here" and "do not use the environment" — and CI legitimately says the
|
|
62
|
+
* second while saying neither: no file, no override, credentials in the environment on purpose. An
|
|
63
|
+
* implication would have made the fix invisible in the diff and unavailable to anyone who wanted only one
|
|
64
|
+
* half of it. So it is its own word, and saying it is a decision somebody made.
|
|
65
|
+
*
|
|
66
|
+
* **It reads as a switch on the overlay, not on the file.** A credential in `<config>/cloudflare.json` was
|
|
67
|
+
* deliberately written there; a credential in the environment is the one nobody in the room put there.
|
|
68
|
+
* Offline plus a scratch config directory therefore resolves *nothing*, which is the guarantee people
|
|
69
|
+
* believed `PITHY_CONFIG_DIR` already gave them — and the guarantee is complete, because a command with no
|
|
70
|
+
* credentials cannot authenticate as anybody.
|
|
71
|
+
*
|
|
72
|
+
* **Where it bites is here rather than in each command**, and that is the whole design. Every
|
|
73
|
+
* out-of-Worker call in the CLI resolves through {@link cloudflareEnv} or {@link resolveCloudflare} —
|
|
74
|
+
* fifteen commands and `pithy doctor`'s two probes — so one gate covers all of them, including the ones
|
|
75
|
+
* written next year. A per-command `--offline` flag would have been the rule-at-the-call-site shape this
|
|
76
|
+
* file already refused once for the account argument, and an environment variable is additionally
|
|
77
|
+
* inherited by a spawned `pithy`, `wrangler`, or test runner, which a flag is not.
|
|
78
|
+
*
|
|
79
|
+
* Any non-blank value. Blank is no override, matching `stateDir`'s reading of `PITHY_CONFIG_DIR`: an
|
|
80
|
+
* unset variable and one a shell script exported empty are the same intention.
|
|
81
|
+
*/
|
|
82
|
+
export const PITHY_OFFLINE_ENV = "PITHY_OFFLINE";
|
|
83
|
+
|
|
84
|
+
/** Whether this environment is refusing ambient credentials and network probes. See {@link PITHY_OFFLINE_ENV}. */
|
|
85
|
+
export function pithyOffline(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
86
|
+
return (env[PITHY_OFFLINE_ENV] ?? "").trim().length > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shape a `cloudflare.accountName` may take: **a bare token** — lowercase letters, digits, and
|
|
91
|
+
* single interior hyphens.
|
|
92
|
+
*
|
|
93
|
+
* **This schema is the gate, and it is the only one there is.** The name becomes a file name in the
|
|
94
|
+
* *config directory*, which sits outside every checkout, so `ensureScaffoldPath` and the atomic writer —
|
|
95
|
+
* the two things that guard a path inside a project — never see it. #174 and #183 were both an
|
|
96
|
+
* unvalidated string reaching somewhere structural, and #183's lesson was to state the rule at the thing
|
|
97
|
+
* being validated rather than at each site that uses it. So `../../etc/passwd`, `a/b`, `a\b`, the empty
|
|
98
|
+
* string, a trailing space, a null byte and a control character are all refused *here*, once, and every
|
|
99
|
+
* way a name can arrive — typed into `pithy.config.ts`, or slugified by `pithy init` from a Cloudflare
|
|
100
|
+
* account's free-text name — goes through this same parse. A second path would make the rule true of
|
|
101
|
+
* only one of them.
|
|
102
|
+
*
|
|
103
|
+
* The refusal names the config and the value, because those are the two things needed to fix it and
|
|
104
|
+
* neither is recoverable from a bare "invalid string".
|
|
105
|
+
*/
|
|
106
|
+
const ACCOUNT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
107
|
+
|
|
108
|
+
/** No file name has any business being longer, and a cap keeps a pathological value off the filesystem. */
|
|
109
|
+
const MAX_ACCOUNT_NAME = 64;
|
|
110
|
+
|
|
111
|
+
export const CloudflareAccountName = z
|
|
112
|
+
.string()
|
|
113
|
+
.max(MAX_ACCOUNT_NAME, {
|
|
114
|
+
error: (issue) =>
|
|
115
|
+
`\`cloudflare.accountName\` in pithy.config.ts is longer than ${MAX_ACCOUNT_NAME} characters: ${JSON.stringify(issue.input)}. It is a file name — <config>/cloudflare.<name>.json — so keep it to a short nickname.`,
|
|
116
|
+
})
|
|
117
|
+
.regex(ACCOUNT_NAME_PATTERN, {
|
|
118
|
+
error: (issue) =>
|
|
119
|
+
`\`cloudflare.accountName\` in pithy.config.ts must be a bare token — lowercase letters, digits, and single hyphens, like "leed". It was ${JSON.stringify(issue.input)}. The name becomes the file name <config>/cloudflare.<name>.json, so a path separator, "..", an empty value, or a control character is refused here rather than where the path is built.`,
|
|
120
|
+
})
|
|
121
|
+
.describe(
|
|
122
|
+
"The nickname a project's root pithy.config.ts gives its Cloudflare account — a bare token, because it becomes <config>/cloudflare.<name>.json.",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** The nickname a project gives its Cloudflare account. Same name as its schema, as every Zod object here is. */
|
|
126
|
+
export type CloudflareAccountName = z.output<typeof CloudflareAccountName>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The credentials file for a named account, or the unnamed default.
|
|
130
|
+
*
|
|
131
|
+
* The name is parsed here as well as at the config's own schema, and that is not belt-and-braces: this
|
|
132
|
+
* is the function that turns a string into a path, so it is the one place that must be impossible to
|
|
133
|
+
* reach with an unvalidated one. Callers that already parsed pay a regex; a caller that did not is
|
|
134
|
+
* refused rather than trusted.
|
|
135
|
+
*/
|
|
136
|
+
export function cloudflareAccountFile(accountName: string | null | undefined): string {
|
|
137
|
+
if (accountName === null || accountName === undefined) return CLOUDFLARE_CONFIG_FILE_NAME;
|
|
138
|
+
const parsed = CloudflareAccountName.safeParse(accountName);
|
|
139
|
+
if (!parsed.success) {
|
|
140
|
+
throw fromZodError(parsed.error, {
|
|
141
|
+
// The schema's sentence, promoted: the CLI renders `message` and `action` and drops `issues`, and a
|
|
142
|
+
// refusal that does not name the value is one the reader has to go and look up.
|
|
143
|
+
message: parsed.error.issues.map((issue) => issue.message).join(" "),
|
|
144
|
+
action: "Set `cloudflare.accountName` in pithy.config.ts to a bare token — lowercase, digits, hyphens.",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return `cloudflare.${parsed.data}.json`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Which Cloudflare account a project belongs to, as its root `pithy.config.ts` declares it (#206).
|
|
152
|
+
*
|
|
153
|
+
* Both fields are optional and they answer different questions. `accountName` selects the credentials
|
|
154
|
+
* file; absent, the file is `cloudflare.json` exactly as before, so a single-account machine is
|
|
155
|
+
* untouched and there is nothing to migrate. `accountId` is a **pin**, not a credential — an account id
|
|
156
|
+
* is an identifier, the kind `wrangler.toml` commits routinely — and it is what makes the nickname mean
|
|
157
|
+
* the same thing on two developers' machines.
|
|
158
|
+
*/
|
|
159
|
+
export interface CloudflareAccountSelection {
|
|
160
|
+
/** Selects `<config>/cloudflare.<accountName>.json`. Absent selects `cloudflare.json`. */
|
|
161
|
+
accountName?: string;
|
|
162
|
+
/** The account the project belongs to. Verified against whatever the credentials resolve to. */
|
|
163
|
+
accountId?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Where the credentials come from, and which account they must belong to.
|
|
168
|
+
*
|
|
169
|
+
* **`account` is required, and there is no ambient behind it.** The first shape of this change kept the
|
|
170
|
+
* selection in a process-wide holder that `loadProject` published into, so a call resolved correctly
|
|
171
|
+
* only if the project had already been loaded. Six call sites resolved credentials before it had —
|
|
172
|
+
* including the pair handed to `wrangler deploy` — and each was silently right on a single-account
|
|
173
|
+
* machine and silently wrong on any other. Six is this repository's usual count for a rule living at
|
|
174
|
+
* call sites instead of at the thing being called, and a holder makes the rule *invisible*: the wrong
|
|
175
|
+
* code is the code with nothing written in it.
|
|
176
|
+
*
|
|
177
|
+
* So the account is an argument with no default. Omitting it is a type error, which is the only form of
|
|
178
|
+
* "you must think about this" that survives the next person in a hurry. `null` is the deliberate answer
|
|
179
|
+
* — this project names no account, or there is no project here — and it is a statement a reviewer can
|
|
180
|
+
* see in a diff, which the omission never was.
|
|
181
|
+
*
|
|
182
|
+
* {@link projectCloudflareAccount} is where a value comes from; it loads the project, so the ordering
|
|
183
|
+
* that used to be implicit is now the `await` in front of it.
|
|
184
|
+
*/
|
|
185
|
+
export interface CloudflareConfigOptions extends StatePathOptions {
|
|
186
|
+
/** The project's account, from `projectCloudflareAccount`, or `null` when it names none. */
|
|
187
|
+
account: CloudflareAccountSelection | null;
|
|
188
|
+
/**
|
|
189
|
+
* Refuse ambient credentials and network probes for this resolution, whatever the environment says.
|
|
190
|
+
*
|
|
191
|
+
* Omitted is "ask the environment" — {@link pithyOffline} — which is how every command inherits the
|
|
192
|
+
* mode without knowing it exists. Present is a caller who has decided, in either direction: `pithy
|
|
193
|
+
* doctor --offline` passes `true` with no variable set, and `false` is a deliberate opt out rather than
|
|
194
|
+
* an absence, so nothing silently re-enables the environment behind a caller that said no.
|
|
195
|
+
*/
|
|
196
|
+
offline?: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The account's Cloudflare configuration as it appears on disk.
|
|
201
|
+
*
|
|
202
|
+
* Every field is optional because every one of them is legitimately absent: a project that has not been
|
|
203
|
+
* provisioned yet has no store id, and most accounts never set `R2_CREDENTIALS`. The keys are
|
|
204
|
+
* {@link CLOUDFLARE_ENV_KEYS} verbatim, so the file, the environment overlay, and every error message
|
|
205
|
+
* name one set of strings.
|
|
206
|
+
*/
|
|
207
|
+
export const CloudflareConfig = z
|
|
208
|
+
.object({
|
|
209
|
+
CLOUDFLARE_ACCOUNT_ID: z
|
|
210
|
+
.string()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("The Cloudflare account id every provisioned resource is created under."),
|
|
213
|
+
CLOUDFLARE_API_TOKEN: z
|
|
214
|
+
.string()
|
|
215
|
+
.optional()
|
|
216
|
+
.describe("The bootstrap API token pithy mints every scoped token from. Never logged, never printed."),
|
|
217
|
+
SECRETS_STORE_ID: z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe("The account's one Secrets Store id. Written by pithy add secrets; never resolved over the network."),
|
|
221
|
+
R2_CREDENTIALS: z
|
|
222
|
+
.string()
|
|
223
|
+
.optional()
|
|
224
|
+
.describe("The account's R2 S3 credentials, as the JSON blob R2Credentials validates. Most projects have none."),
|
|
225
|
+
})
|
|
226
|
+
.catchall(z.unknown().describe("Another tenant's key, read and written back untouched."))
|
|
227
|
+
.describe("This machine's account-scoped Cloudflare credentials — <config>/cloudflare.json, mode 0600.");
|
|
228
|
+
|
|
229
|
+
/** The account's Cloudflare configuration. Same name as its schema, as every Zod object in this repo is. */
|
|
230
|
+
export type CloudflareConfig = z.output<typeof CloudflareConfig>;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `<config>/cloudflare.json`, or `<config>/cloudflare.<accountName>.json` when the project names an
|
|
234
|
+
* account — beside `state.json`, above every project's own directory.
|
|
235
|
+
*/
|
|
236
|
+
export function cloudflareConfigPath(options: CloudflareConfigOptions): string {
|
|
237
|
+
return join(stateDir(options), cloudflareAccountFile(options.account?.accountName));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Where a resolved `CLOUDFLARE_ACCOUNT_ID` came from — the two sources {@link cloudflareEnv} merges. */
|
|
241
|
+
export type CloudflareAccountSource = "file" | "environment";
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A project's pin disagreeing with the account the credentials actually belong to.
|
|
245
|
+
*
|
|
246
|
+
* `cloudflare.<name>.json` is a local file, so the nickname means whatever each machine says it means:
|
|
247
|
+
* two developers on one repository can both have that file and have it point at different accounts, and
|
|
248
|
+
* nothing in the repository would disagree with either of them. The pin is what makes the repository the
|
|
249
|
+
* authority — the local file may only supply credentials *for the account the project says it belongs to*.
|
|
250
|
+
*/
|
|
251
|
+
export interface CloudflareAccountMismatch {
|
|
252
|
+
/** `cloudflare.accountId` from the project's root config. */
|
|
253
|
+
pinned: string;
|
|
254
|
+
/** The `CLOUDFLARE_ACCOUNT_ID` that actually resolved. */
|
|
255
|
+
resolved: string;
|
|
256
|
+
/** Which of the two sources supplied it — the environment overlay is the CI case. */
|
|
257
|
+
source: CloudflareAccountSource;
|
|
258
|
+
/** The credentials file this resolution named, whether or not it existed. */
|
|
259
|
+
path: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Which of the two sources supplied the pair that would authenticate — `mixed` when each supplied one key.
|
|
264
|
+
*
|
|
265
|
+
* **Reported because the file this run *resolved* is not the file the credentials *came from*.** The
|
|
266
|
+
* `Cloudflare:` line has named the resolved path since #206, and in CI — and in the sandbox that prompted
|
|
267
|
+
* #218 — that is a path with nothing at it, while a token from the shell does the authenticating. Naming
|
|
268
|
+
* a file and using the environment is the shape of the whole incident, so the source is its own fact.
|
|
269
|
+
*/
|
|
270
|
+
export type CloudflareCredentialSource = CloudflareAccountSource | "mixed";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* What vouches for the account a run is about to act on — the answer that does not need a pin (#378).
|
|
274
|
+
*
|
|
275
|
+
* **A mismatch is only detectable when the project pins `cloudflare.accountId`.** A project that pins
|
|
276
|
+
* none has no disagreement to find, so {@link CloudflareAccountMismatch} stays `null` and every listing
|
|
277
|
+
* downstream answers confidently about whatever `CLOUDFLARE_ACCOUNT_ID` the shell happened to export.
|
|
278
|
+
* That is the gap: the guard was real and it was narrow, and the sites outside it can delete nothing
|
|
279
|
+
* while reporting success, or create a resource in a stranger's account.
|
|
280
|
+
*
|
|
281
|
+
* So confirmation is a *level*, always present, and it is a statement about **who decided the account**:
|
|
282
|
+
*
|
|
283
|
+
* - `pinned` — the repository pins the id and the credentials agree. The strong form, and the only one
|
|
284
|
+
* that means the same thing on two developers' machines.
|
|
285
|
+
* - `named` — the repository names `cloudflare.accountName`, and **that file** supplied the id. The
|
|
286
|
+
* repository chose the file; the operator wrote the file for that nickname. Weaker than a pin, and not
|
|
287
|
+
* ambient: an exported variable cannot produce it, because the overlay only fills keys the file left unset.
|
|
288
|
+
* - `recorded` — the repository says nothing, and the default `<config>/cloudflare.json` supplied the id.
|
|
289
|
+
* This is the single-account machine `pithy init` wrote, untouched since #206, and refusing it would
|
|
290
|
+
* break every project that never needed a second account.
|
|
291
|
+
* - `ambient` — nothing in the repository and nothing on disk decided. The id came from `process.env`.
|
|
292
|
+
* **This is the one value that refuses**, and it is exactly the incident: on 2026-08-15 a lane read
|
|
293
|
+
* three databases belonging to a different account out of a shell that had exported one variable.
|
|
294
|
+
*
|
|
295
|
+
* The escape from `ambient` is one line of config, and the refusal names it. That is deliberate — a
|
|
296
|
+
* command that creates or destroys infrastructure should be told which account it is for.
|
|
297
|
+
*/
|
|
298
|
+
export type CloudflareAccountConfirmation = "pinned" | "named" | "recorded" | "ambient";
|
|
299
|
+
|
|
300
|
+
/** One credential resolution, with everything a caller may need to refuse or to report it. */
|
|
301
|
+
export interface CloudflareResolution {
|
|
302
|
+
/** The file this resolution read, named whether or not it exists. */
|
|
303
|
+
path: string;
|
|
304
|
+
/** The `cloudflare.accountName` that selected it, or `null` for the unnamed default. */
|
|
305
|
+
accountName: string | null;
|
|
306
|
+
/** The credentials: the file's keys, with `process.env` overlaid per key for the ones it did not set. */
|
|
307
|
+
vars: Record<string, string>;
|
|
308
|
+
/** The project's `cloudflare.accountId`, or `null` when it set none. */
|
|
309
|
+
pinnedAccountId: string | null;
|
|
310
|
+
/** Set when the pin and the resolved account id disagree. */
|
|
311
|
+
mismatch: CloudflareAccountMismatch | null;
|
|
312
|
+
/**
|
|
313
|
+
* What vouches for the resolved account, whether or not the project pins one (#378).
|
|
314
|
+
*
|
|
315
|
+
* Always present, and `ambient` where {@link mismatch} could say nothing at all. A caller that is
|
|
316
|
+
* about to create or delete infrastructure reads this; a caller that is only reporting reads it too,
|
|
317
|
+
* and says so rather than refusing.
|
|
318
|
+
*/
|
|
319
|
+
confirmation: CloudflareAccountConfirmation;
|
|
320
|
+
/** Whether this resolution refused the environment overlay — see {@link PITHY_OFFLINE_ENV}. */
|
|
321
|
+
offline: boolean;
|
|
322
|
+
/**
|
|
323
|
+
* Where the {@link CLOUDFLARE_CREDENTIAL_KEYS} pair came from, or `null` when neither key resolved.
|
|
324
|
+
*
|
|
325
|
+
* The pair alone, for the reason `CLOUDFLARE_CREDENTIAL_KEYS` gives: a store id from the environment
|
|
326
|
+
* says nothing about which account a run authenticates as, and only the pair can quietly succeed
|
|
327
|
+
* somewhere unintended.
|
|
328
|
+
*/
|
|
329
|
+
credentialSource: CloudflareCredentialSource | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolve the credentials **and** everything that can be said about them, without throwing.
|
|
334
|
+
*
|
|
335
|
+
* The non-throwing half of {@link cloudflareEnv}, for the two callers that must keep working in exactly
|
|
336
|
+
* the state they exist to report: `pithy doctor`, which turns a mismatch into a line and a non-zero exit,
|
|
337
|
+
* and `pithy add secrets`, whose store-id recording is a convenience that may never fail the command it
|
|
338
|
+
* rides on.
|
|
339
|
+
*/
|
|
340
|
+
export function resolveCloudflare(options: CloudflareConfigOptions): CloudflareResolution {
|
|
341
|
+
const selection = options.account;
|
|
342
|
+
const accountName = selection?.accountName ?? null;
|
|
343
|
+
const path = join(stateDir(options), cloudflareAccountFile(accountName));
|
|
344
|
+
const fromFile = readCloudflareConfigSync(path);
|
|
345
|
+
const vars = { ...fromFile };
|
|
346
|
+
const env = options.env ?? process.env;
|
|
347
|
+
// The one gate, for every command that resolves credentials. The overlay is otherwise untouched — see
|
|
348
|
+
// {@link PITHY_OFFLINE_ENV} for why this is a word somebody says rather than something PITHY_CONFIG_DIR
|
|
349
|
+
// implies.
|
|
350
|
+
const offline = options.offline ?? pithyOffline(env);
|
|
351
|
+
if (!offline) {
|
|
352
|
+
for (const key of CLOUDFLARE_ENV_KEYS) {
|
|
353
|
+
const overlay = env[key];
|
|
354
|
+
if (!vars[key] && overlay) vars[key] = overlay;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const credentialSource = sourceOf(fromFile, vars);
|
|
358
|
+
|
|
359
|
+
const pinnedAccountId = selection?.accountId ?? null;
|
|
360
|
+
const resolved = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
361
|
+
// A pin with nothing resolved is not a mismatch: unconfigured is a legitimate state, and `pithy doctor`
|
|
362
|
+
// already names it every run. Only a *disagreement* is a fault.
|
|
363
|
+
const mismatch =
|
|
364
|
+
pinnedAccountId && resolved && resolved !== pinnedAccountId
|
|
365
|
+
? {
|
|
366
|
+
pinned: pinnedAccountId,
|
|
367
|
+
resolved,
|
|
368
|
+
source: (fromFile.CLOUDFLARE_ACCOUNT_ID ? "file" : "environment") as CloudflareAccountSource,
|
|
369
|
+
path,
|
|
370
|
+
}
|
|
371
|
+
: null;
|
|
372
|
+
|
|
373
|
+
const confirmation = confirmationOf({
|
|
374
|
+
pinnedAccountId,
|
|
375
|
+
accountName,
|
|
376
|
+
resolved,
|
|
377
|
+
fromFileAccountId: fromFile.CLOUDFLARE_ACCOUNT_ID ?? "",
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return { path, accountName, vars, pinnedAccountId, mismatch, offline, credentialSource, confirmation };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Who decided the account, from the three places that could have.
|
|
385
|
+
*
|
|
386
|
+
* Derived from this one resolution rather than asked again, for the reason {@link sourceOf} gives. Order
|
|
387
|
+
* matters and it is the order of authority: the repository's pin, then the repository's file selection,
|
|
388
|
+
* then the machine's default file, then nothing.
|
|
389
|
+
*/
|
|
390
|
+
function confirmationOf(facts: {
|
|
391
|
+
pinnedAccountId: string | null;
|
|
392
|
+
accountName: string | null;
|
|
393
|
+
resolved: string;
|
|
394
|
+
fromFileAccountId: string;
|
|
395
|
+
}): CloudflareAccountConfirmation {
|
|
396
|
+
// A pin that survived the mismatch check above vouches for whatever resolved, from either source: the
|
|
397
|
+
// repository named the id and the credentials agree with it.
|
|
398
|
+
if (facts.pinnedAccountId && facts.resolved === facts.pinnedAccountId) return "pinned";
|
|
399
|
+
// Nothing resolved is not confirmation of anything. Callers that need credentials refuse for the
|
|
400
|
+
// missing pair first; this is the honest value for the state where there is no account to vouch for.
|
|
401
|
+
if (!facts.resolved) return "ambient";
|
|
402
|
+
// The file has to be the one that supplied the id. A named file holding only a token leaves the
|
|
403
|
+
// account to the overlay, which is the ambient case wearing the project's nickname.
|
|
404
|
+
if (facts.fromFileAccountId !== facts.resolved) return "ambient";
|
|
405
|
+
return facts.accountName === null ? "recorded" : "named";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Which source decided the account pair, from the file's keys and the resolved set.
|
|
410
|
+
*
|
|
411
|
+
* Derived from one read rather than taken a second time, so what is reported is a fact about *this*
|
|
412
|
+
* resolution — the same rule `checkCloudflareAccess` follows for the split. A key present in the resolved
|
|
413
|
+
* set and absent from the file came from the overlay, which is the only other place it could have come from.
|
|
414
|
+
*/
|
|
415
|
+
function sourceOf(fromFile: Record<string, string>, vars: Record<string, string>): CloudflareCredentialSource | null {
|
|
416
|
+
const resolved = CLOUDFLARE_CREDENTIAL_KEYS.filter((key) => Boolean(vars[key]));
|
|
417
|
+
if (resolved.length === 0) return null;
|
|
418
|
+
const file = resolved.filter((key) => Boolean(fromFile[key]));
|
|
419
|
+
if (file.length === resolved.length) return "file";
|
|
420
|
+
if (file.length === 0) return "environment";
|
|
421
|
+
return "mixed";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The one sentence a mismatch gets, so the refusal and `pithy doctor`'s line say the same thing.
|
|
426
|
+
*
|
|
427
|
+
* Both ids are named, because the whole failure is that two of them exist and nothing else in the
|
|
428
|
+
* toolchain compares them. The source is named too: a file naming the wrong account is a local
|
|
429
|
+
* misconfiguration, and an *environment* naming it is a CI job pointed at the wrong tenant — the one
|
|
430
|
+
* place that deploys to production.
|
|
431
|
+
*/
|
|
432
|
+
export function describeCloudflareAccountMismatch(mismatch: CloudflareAccountMismatch): string {
|
|
433
|
+
const where = mismatch.source === "file" ? mismatch.path : "the environment";
|
|
434
|
+
return `This project pins Cloudflare account ${mismatch.pinned}, and ${where} supplies credentials for ${mismatch.resolved}.`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The one sentence an unconfirmed account gets — {@link describeCloudflareAccountMismatch}'s sibling, for
|
|
439
|
+
* the case that has no disagreement to report because nothing was ever claimed (#378).
|
|
440
|
+
*
|
|
441
|
+
* The account id is named because it is the whole point: an operator reading this is being shown the
|
|
442
|
+
* account their command was about to change, and it is very likely not the one they had in mind.
|
|
443
|
+
*/
|
|
444
|
+
export function describeUnconfirmedCloudflareAccount(accountId: string): string {
|
|
445
|
+
return `Nothing states that Cloudflare account ${accountId} is this project's.`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** What a project may do about {@link describeUnconfirmedCloudflareAccount}. One line of config, either way. */
|
|
449
|
+
export const UNCONFIRMED_CLOUDFLARE_ACCOUNT_ACTION =
|
|
450
|
+
"Set `cloudflare.accountId` in pithy.config.ts to the account this project belongs to, or `cloudflare.accountName` to the file holding its credentials. An account id from the environment is not a claim the repository makes.";
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* What vouches for the account these options resolve to.
|
|
454
|
+
*
|
|
455
|
+
* A thin read of {@link resolveCloudflare}, so a caller that already refuses through {@link cloudflareEnv}
|
|
456
|
+
* adds one line rather than a second resolution path. It never throws: an unconfirmed account is not a
|
|
457
|
+
* fault everywhere, and each site decides for itself — a teardown refuses, `pithy doctor` reports.
|
|
458
|
+
*/
|
|
459
|
+
export function cloudflareAccountConfirmation(options: CloudflareConfigOptions): CloudflareAccountConfirmation {
|
|
460
|
+
return resolveCloudflare(options).confirmation;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The Cloudflare credentials every out-of-Worker call resolves: `<config>/cloudflare.json`, then
|
|
465
|
+
* `process.env` for any {@link CLOUDFLARE_ENV_KEYS} the file did not set.
|
|
466
|
+
*
|
|
467
|
+
* **Synchronous, and that is load-bearing for the twenty-odd call sites.** Every command resolves these
|
|
468
|
+
* before doing anything else; making the read async would turn a settled call into a refactor of each.
|
|
469
|
+
*
|
|
470
|
+
* **A missing or unreadable file is not an error here.** CI has no file and passes the whole set as
|
|
471
|
+
* environment variables; a developer who has not run `pithy init` yet has neither. The overlay applies
|
|
472
|
+
* per key, so a file holding only the account id still takes the token from the environment — which is
|
|
473
|
+
* exactly the mixture {@link cloudflareCredentialSplit} exists to name.
|
|
474
|
+
*/
|
|
475
|
+
export function cloudflareEnv(options: CloudflareConfigOptions): Record<string, string> {
|
|
476
|
+
const resolved = resolveCloudflare(options);
|
|
477
|
+
if (resolved.mismatch) {
|
|
478
|
+
throw new ConflictError({
|
|
479
|
+
message: describeCloudflareAccountMismatch(resolved.mismatch),
|
|
480
|
+
action:
|
|
481
|
+
"Fix `cloudflare.accountId` in pithy.config.ts, or point `cloudflare.accountName` at the file holding this account's credentials. Pithy will not use credentials for an account the project does not claim.",
|
|
482
|
+
detail: `pinned ${resolved.mismatch.pinned}; ${resolved.mismatch.source} supplied ${resolved.mismatch.resolved} (${resolved.mismatch.path})`,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return resolved.vars;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The pair that authenticates a Cloudflare call, once both halves are known to be there. */
|
|
489
|
+
export interface CloudflareCredentials {
|
|
490
|
+
/** `CLOUDFLARE_ACCOUNT_ID` — the account every resolved resource is asked for. Never blank. */
|
|
491
|
+
accountId: string;
|
|
492
|
+
/** `CLOUDFLARE_API_TOKEN` — the token that asks. Never blank, never logged. */
|
|
493
|
+
apiToken: string;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The credentials a live call needs, or a refusal that says which of the two things is wrong (#236).
|
|
498
|
+
*
|
|
499
|
+
* **Two failures, in the order they are settled.** A pin the credentials contradict is
|
|
500
|
+
* {@link cloudflareEnv}'s throw, in the one sentence {@link describeCloudflareAccountMismatch} spells and
|
|
501
|
+
* `pithy doctor` already reads out — reused rather than restated, because two spellings of one diagnosis
|
|
502
|
+
* is how they drift. Nothing resolving at all is this function's, and it is the *second* question:
|
|
503
|
+
* "unconfigured" is a fair thing to say about a machine with no file, and a lie about one whose file names
|
|
504
|
+
* another company's account.
|
|
505
|
+
*
|
|
506
|
+
* **It exists so a caller can settle the account before it starts fanning out.** Both facts are
|
|
507
|
+
* whole-project — true before any Worker, binding, or resource is considered — and a caller that consults
|
|
508
|
+
* them per item collects them per item. #236 was exactly that: an account mismatch reached
|
|
509
|
+
* `openConnectionRegistry`'s per-Worker refusal list and was summarized as "No worker resolves the DB
|
|
510
|
+
* binding for this environment", a sentence about a `wrangler.jsonc` that was fine.
|
|
511
|
+
*/
|
|
512
|
+
export function cloudflareCredentials(options: CloudflareConfigOptions): CloudflareCredentials {
|
|
513
|
+
const vars = cloudflareEnv(options);
|
|
514
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
515
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
516
|
+
if (!accountId || !apiToken) {
|
|
517
|
+
throw new ValidationError({
|
|
518
|
+
message: "Cloudflare credentials are missing.",
|
|
519
|
+
action:
|
|
520
|
+
"Run pithy init to store this account's credentials, or set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in the environment.",
|
|
521
|
+
detail: `resolved from ${cloudflareConfigPath(options)}; missing ${CLOUDFLARE_CREDENTIAL_KEYS.filter((key) => !vars[key]).join(", ")}`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return { accountId, apiToken };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** One credential group assembled from two places: what the file supplied, and what the environment filled in. */
|
|
528
|
+
export interface CloudflareCredentialSplit {
|
|
529
|
+
/** The {@link CLOUDFLARE_CREDENTIAL_KEYS} `cloudflare.json` sets. Non-empty, or there is no split. */
|
|
530
|
+
fromFile: string[];
|
|
531
|
+
/** The rest of the group, which {@link cloudflareEnv} therefore overlays from the environment. Non-empty. */
|
|
532
|
+
fromEnvironment: string[];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Whether the effective Cloudflare credentials are **assembled from two sources** — some of
|
|
537
|
+
* {@link CLOUDFLARE_CREDENTIAL_KEYS} from `cloudflare.json`, the rest overlaid from the ambient
|
|
538
|
+
* environment.
|
|
539
|
+
*
|
|
540
|
+
* {@link cloudflareEnv} overlays *per key*, so a file that sets only `CLOUDFLARE_API_TOKEN` silently
|
|
541
|
+
* takes `CLOUDFLARE_ACCOUNT_ID` from whatever the shell exports. Nothing disagrees, nothing warns, and
|
|
542
|
+
* the run authenticates as one account against another account's id — a confusing 403, or an empty
|
|
543
|
+
* listing, at some much later call.
|
|
544
|
+
*
|
|
545
|
+
* **What it cannot see.** It checks that one source decided the group, not that the group is right. A
|
|
546
|
+
* complete file naming the wrong account is invisible here, and so is a complete pair in the environment
|
|
547
|
+
* that you never meant to use — both are coherent, and coherent is all this can judge.
|
|
548
|
+
*
|
|
549
|
+
* A complete file, a complete absence of one (CI passes the pair as environment variables), and a half
|
|
550
|
+
* file with nothing to fill the other half are all silent. An empty value counts as unset, matching the
|
|
551
|
+
* overlay exactly, so this reports the credentials that actually resolve.
|
|
552
|
+
*
|
|
553
|
+
* **Offline is silent too**, for that last reason rather than as an exception to it: with the overlay
|
|
554
|
+
* refused there is nothing to fill the file's other half, so half a file is unconfigured — exactly the
|
|
555
|
+
* state this already says nothing about.
|
|
556
|
+
*/
|
|
557
|
+
export function cloudflareCredentialSplit(options: CloudflareConfigOptions): CloudflareCredentialSplit | null {
|
|
558
|
+
const path = cloudflareConfigPath(options);
|
|
559
|
+
let file: Record<string, string>;
|
|
560
|
+
try {
|
|
561
|
+
file = parseCloudflareConfig(readFileSync(path, "utf8"));
|
|
562
|
+
} catch {
|
|
563
|
+
return null; // No file — the environment supplies the whole group, which is how CI runs.
|
|
564
|
+
}
|
|
565
|
+
const env = (options.offline ?? pithyOffline(options.env ?? process.env)) ? {} : (options.env ?? process.env);
|
|
566
|
+
const fromFile = CLOUDFLARE_CREDENTIAL_KEYS.filter((key) => Boolean(file[key]));
|
|
567
|
+
const fromEnvironment = CLOUDFLARE_CREDENTIAL_KEYS.filter((key) => !file[key] && Boolean(env[key]));
|
|
568
|
+
if (fromFile.length === 0 || fromEnvironment.length === 0) return null;
|
|
569
|
+
return { fromFile: [...fromFile], fromEnvironment: [...fromEnvironment] };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Parse the file's bytes into the string-valued keys a caller resolves credentials from.
|
|
574
|
+
*
|
|
575
|
+
* Validated because it is read off disk and hand-editable, and it decides which account every
|
|
576
|
+
* provisioning call lands in. A non-string value would reach a Cloudflare client as an object and
|
|
577
|
+
* authenticate as `[object Object]`; a malformed document resolves to no credentials rather than to
|
|
578
|
+
* half of somebody else's.
|
|
579
|
+
*/
|
|
580
|
+
export function parseCloudflareConfig(source: string): Record<string, string> {
|
|
581
|
+
const parsed = CloudflareConfig.safeParse(safeJson(source));
|
|
582
|
+
if (!parsed.success) return {};
|
|
583
|
+
const vars: Record<string, string> = {};
|
|
584
|
+
for (const key of CLOUDFLARE_ENV_KEYS) {
|
|
585
|
+
const value = parsed.data[key];
|
|
586
|
+
if (typeof value === "string" && value !== "") vars[key] = value;
|
|
587
|
+
}
|
|
588
|
+
return vars;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Merge `values` into the account's config and write it back. Returns the merged set.
|
|
593
|
+
*
|
|
594
|
+
* **Read-modify-write over a credential file, so an unreadable-but-present one refuses rather than being
|
|
595
|
+
* replaced.** Only `ENOENT` licenses starting from `{}` — every other errno is a file that exists and did
|
|
596
|
+
* not open, and writing over it would delete a token nothing else has a copy of. That rule is
|
|
597
|
+
* {@link readOptionalFile}'s; the sentence below is this file's.
|
|
598
|
+
*
|
|
599
|
+
* Written `0600` in a `0700` directory, and narrowed again after the write: a file an editor or an older
|
|
600
|
+
* pithy created at the umask holds a live API token at `0644` until something narrows it.
|
|
601
|
+
*/
|
|
602
|
+
export async function writeCloudflareConfig(
|
|
603
|
+
values: Partial<Record<(typeof CLOUDFLARE_ENV_KEYS)[number], string>>,
|
|
604
|
+
options: CloudflareConfigOptions,
|
|
605
|
+
): Promise<Record<string, string>> {
|
|
606
|
+
const path = cloudflareConfigPath(options);
|
|
607
|
+
const document = await readCloudflareDocument(path);
|
|
608
|
+
const merged: Record<string, unknown> = { ...document };
|
|
609
|
+
for (const [key, value] of Object.entries(values)) {
|
|
610
|
+
if (value === undefined) continue;
|
|
611
|
+
merged[key] = value;
|
|
612
|
+
}
|
|
613
|
+
await ensureOwnerOnlyDirFor(path);
|
|
614
|
+
await writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
615
|
+
await tightenMode(path);
|
|
616
|
+
return parseCloudflareConfig(JSON.stringify(merged));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The parsed document with every tenant's key intact, for a read-modify-write. `{}` only for an absent file.
|
|
621
|
+
*
|
|
622
|
+
* **One key could not be kept, and the `catchall` above promised it would be.** `JSON.parse` gives
|
|
623
|
+
* `__proto__` an own property; Zod skips that key while building the object it returns, for the reason
|
|
624
|
+
* {@link statesNoVanishingKey} sets out; the write then puts back what it was handed. The input stated the
|
|
625
|
+
* key, the output did not, and nothing was reported in between.
|
|
626
|
+
*
|
|
627
|
+
* Refused rather than preserved. `__proto__` is not a name a future tenant will be called, and this file
|
|
628
|
+
* holds a live API token — the one document where a key deleted in silence must not be answered with a
|
|
629
|
+
* shrug. Refusing is also what makes the refusal reachable: the guard runs *in front of* the parse,
|
|
630
|
+
* because by the time the parse has returned there is nothing left to notice.
|
|
631
|
+
*
|
|
632
|
+
* A malformed document keeps its existing answer — `{}`, and a write that starts from nothing — because
|
|
633
|
+
* that is a different failure with a different cost, and this is not the issue that settles it.
|
|
634
|
+
*/
|
|
635
|
+
async function readCloudflareDocument(path: string): Promise<Record<string, unknown>> {
|
|
636
|
+
const source = await readOptionalFile(path, {
|
|
637
|
+
unreadable: ({ code, cause }) =>
|
|
638
|
+
new ConflictError(
|
|
639
|
+
{
|
|
640
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
641
|
+
action:
|
|
642
|
+
"Fix the file's permissions, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not read.",
|
|
643
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
644
|
+
},
|
|
645
|
+
{ cause },
|
|
646
|
+
),
|
|
647
|
+
});
|
|
648
|
+
if (source === null) return {};
|
|
649
|
+
const document = safeJson(source);
|
|
650
|
+
if (!statesNoVanishingKey(document)) {
|
|
651
|
+
throw new ConflictError({
|
|
652
|
+
message: `Cannot update ${path}: it states a "__proto__" key, and reading it would lose that key without a word.`,
|
|
653
|
+
action: "Remove the key, or move the file aside, and run the command again.",
|
|
654
|
+
detail: `a "__proto__" key in ${path} does not survive being parsed, so a read-modify-write would delete it`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const parsed = CloudflareConfig.safeParse(document);
|
|
658
|
+
return parsed.success ? parsed.data : {};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The file's credential keys, or none.
|
|
663
|
+
*
|
|
664
|
+
* **Every failure is an empty set here, where it is a refusal in the writer.** Nothing is rewritten from
|
|
665
|
+
* this call — the result is handed to a Cloudflare client — so a file that will not open costs a set of
|
|
666
|
+
* credentials and says so through `pithy doctor`'s `Cloudflare: unconfigured`, rather than stopping every
|
|
667
|
+
* command in the CLI over a permission bit. {@link writeCloudflareConfig} is the one that refuses,
|
|
668
|
+
* because it is the one that could destroy what it could not read.
|
|
669
|
+
*/
|
|
670
|
+
function readCloudflareConfigSync(path: string): Record<string, string> {
|
|
671
|
+
try {
|
|
672
|
+
return parseCloudflareConfig(readFileSync(path, "utf8"));
|
|
673
|
+
} catch {
|
|
674
|
+
return {};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** `JSON.parse` that answers `undefined` rather than throwing — this file is hand-edited. */
|
|
679
|
+
function safeJson(source: string): unknown {
|
|
680
|
+
try {
|
|
681
|
+
return JSON.parse(source);
|
|
682
|
+
} catch {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
}
|