@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,265 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
8
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
9
|
+
import { loadProject, requireProjectName } from "../project/config";
|
|
10
|
+
import { type MergeBase, readMergeBase } from "../project/readOptionalFile";
|
|
11
|
+
import { devPreferencesPath } from "../seed/prepare";
|
|
12
|
+
import { ensureDevSecretsDir } from "./location";
|
|
13
|
+
import { tightenMode } from "./mode";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bootstrap half of a generated `.dev.vars`: the values a Worker reads from an **env binding** rather
|
|
17
|
+
* than from a secrets store — the dev master key, a `cf-secrets-store` secret, a Turnstile sitekey.
|
|
18
|
+
*
|
|
19
|
+
* **It lives in `dev.json`, as a second tenant (#154).** `<config>/<project>/dev.json` was already there
|
|
20
|
+
* for dev-login preferences (#131), it is already machine-local, already outside every checkout, and
|
|
21
|
+
* already the file `pithy doctor` names. Inventing a second location for "the machine's dev values" would
|
|
22
|
+
* have been a third convention for the same directory.
|
|
23
|
+
*
|
|
24
|
+
* **Why it has to persist anywhere at all.** `.dev.vars` used to be the store: `pithy add secrets` minted
|
|
25
|
+
* a master key into it and every later run read it back from there. A generated file cannot be its own
|
|
26
|
+
* source of truth — regenerating it would mean reading what the last generation wrote, which is the
|
|
27
|
+
* accumulating file this change exists to end. So the values move one level out, to the place the
|
|
28
|
+
* secrets file already went, and the generated file becomes a pure function of them.
|
|
29
|
+
*
|
|
30
|
+
* **Not a second copy of the secrets file.** `secrets.jsonc` holds application secrets as versioned
|
|
31
|
+
* envelopes, seeded into a Worker's local `SECRETS` store. This holds the flat `KEY=value` set that has
|
|
32
|
+
* to be a binding, because dev has no Secrets Store to read one from. A `d1` secret is never here.
|
|
33
|
+
*
|
|
34
|
+
* Same directory, same 0700, same 0600 as its neighbor — for the same reason: the dev master key is in
|
|
35
|
+
* it, and the umask is not a permission policy for a credential.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The `dev.json` key this set lives under. Namespaced, because the file has other tenants. */
|
|
39
|
+
export const BOOTSTRAP_VARS_KEY = "vars";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The bootstrap set as it appears in `dev.json` — a flat map of `.dev.vars` variable names to values.
|
|
43
|
+
*
|
|
44
|
+
* Validated because it is read off disk and hand-editable, and it decides what a Worker's bindings are.
|
|
45
|
+
* A non-string value would reach {@link import("./devVars").encodeDevVarsValue} as an object and be
|
|
46
|
+
* written as `[object Object]`.
|
|
47
|
+
*/
|
|
48
|
+
export const BootstrapVars = z
|
|
49
|
+
.record(
|
|
50
|
+
z.string().describe("The `.dev.vars` variable name — wrangler's namespace, so UPPER_SNAKE by convention."),
|
|
51
|
+
z.string().describe("The value the Worker's binding carries. Never logged, never printed, never in --json."),
|
|
52
|
+
)
|
|
53
|
+
.describe("Every value this machine has to hand a Worker through a .dev.vars binding, by variable name.");
|
|
54
|
+
|
|
55
|
+
/** The bootstrap set. Same name as its schema, as every Zod object in this repo is. */
|
|
56
|
+
export type BootstrapVars = z.output<typeof BootstrapVars>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The shape this module reads `dev.json` as: its own key, and every other tenant's, preserved.
|
|
60
|
+
*
|
|
61
|
+
* `catchall` rather than a closed object, because a write here must not delete the dev-login preferences
|
|
62
|
+
* sitting beside it. Two tenants in one file is the whole reason the key exists.
|
|
63
|
+
*
|
|
64
|
+
* **Exported so a caller planning a write into this file reads it through one schema (#222).** A
|
|
65
|
+
* planning read goes through `readMergeBase` against this schema, so what it computes is a `dev.json`
|
|
66
|
+
* that was actually read rather than a `{}` a lenient read invented. One schema, because two would be
|
|
67
|
+
* two answers to "what is in that file", and a plan is what somebody deletes a line on the strength of.
|
|
68
|
+
*/
|
|
69
|
+
export const DevJson = z
|
|
70
|
+
.object({
|
|
71
|
+
[BOOTSTRAP_VARS_KEY]: BootstrapVars.optional().describe("The bootstrap `.dev.vars` set — this module's tenant."),
|
|
72
|
+
})
|
|
73
|
+
.catchall(z.unknown().describe("Another tenant's key, read and written back untouched."))
|
|
74
|
+
.describe("This machine's per-project dev file, of which the bootstrap vars are one tenant.");
|
|
75
|
+
|
|
76
|
+
/** The whole file, every tenant included. Same name as its schema, as every Zod object in this repo is. */
|
|
77
|
+
export type DevJson = z.output<typeof DevJson>;
|
|
78
|
+
|
|
79
|
+
/** `<config>/<project>/dev.json` for a project root, or `null` when the project has no name to key on. */
|
|
80
|
+
export async function bootstrapVarsPath(projectDir: string, options: StatePathOptions = {}): Promise<string | null> {
|
|
81
|
+
try {
|
|
82
|
+
return devPreferencesPath(requireProjectName(await loadProject(projectDir)), options);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* This project's bootstrap set, or an empty one. **The reporting read of the two in this file.**
|
|
90
|
+
*
|
|
91
|
+
* **Every failure is an empty set, and that is deliberate here where it is a defect elsewhere.** The
|
|
92
|
+
* sibling readers refuse an unreadable credential file because they are about to *rewrite* it, and
|
|
93
|
+
* "absent" would license replacing what they could not read. Nothing is rewritten from this call — the
|
|
94
|
+
* result is merged into a file that is regenerated wholesale — so a `dev.json` that will not parse costs
|
|
95
|
+
* a set of bindings and says so through the Worker's own missing-binding error, rather than stopping
|
|
96
|
+
* `pithy dev` over a hand-edited preferences file. This is the read `pithy dev` starts through.
|
|
97
|
+
*
|
|
98
|
+
* **What that argument is not about (#219).** It is an argument about *this call*, and it was read for a
|
|
99
|
+
* while as if it were about the file: the two writers below reached the same `{}` through
|
|
100
|
+
* {@link readDevJson} and renamed a document built from it over a file with another tenant in it. They
|
|
101
|
+
* refuse now, in every state but "there is no file", and this one still answers `{}` — the split is the
|
|
102
|
+
* fix, not a tightening. It is a type rather than a convention: they take a {@link MergeBase}, which only
|
|
103
|
+
* `readMergeBase` mints, so nothing that reads leniently can be handed to them by accident.
|
|
104
|
+
*
|
|
105
|
+
* So this read deliberately does *not* go through the primitive, and it is written down as such in that
|
|
106
|
+
* module's gate (`../project/readOptionalFile.test.ts`) rather than left to be rediscovered.
|
|
107
|
+
*/
|
|
108
|
+
export async function readBootstrapVars(projectDir: string, options: StatePathOptions = {}): Promise<BootstrapVars> {
|
|
109
|
+
const path = await bootstrapVarsPath(projectDir, options);
|
|
110
|
+
if (path === null) return {};
|
|
111
|
+
const source = await readFile(path, "utf8").catch(() => null);
|
|
112
|
+
if (source === null) return {};
|
|
113
|
+
const parsed = DevJson.safeParse(safeJson(source));
|
|
114
|
+
if (!parsed.success) return {};
|
|
115
|
+
return parsed.data[BOOTSTRAP_VARS_KEY] ?? {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Merge `values` into this project's bootstrap set and write `dev.json` back. Returns the merged set,
|
|
120
|
+
* so a caller regenerating from it never re-reads what it has just written.
|
|
121
|
+
*
|
|
122
|
+
* **Read-modify-write over a file with other tenants**, so every state but "there is no file" refuses:
|
|
123
|
+
* writing this set over a `dev.json` that could not be read, or is not JSON, or parsed to something that
|
|
124
|
+
* is not a document, or is a record of something else, would silently delete a developer's dev-login
|
|
125
|
+
* preference along with every value this module had. Only `ENOENT` licenses starting from `{}`.
|
|
126
|
+
*
|
|
127
|
+
* A value is never removed here. A name that leaves the registry leaves a line nothing reads; `pithy
|
|
128
|
+
* doctor` is where a value nobody declares gets named, in this file exactly as in its neighbor.
|
|
129
|
+
*
|
|
130
|
+
* **An empty `values` returns before the read, and that is a decision (#222).** The refusal above exists
|
|
131
|
+
* because a merge into an invented `{}` gets renamed over another tenant's keys; a call that writes
|
|
132
|
+
* nothing performs no merge and no rename, so there is nothing for it to protect. It was reached —
|
|
133
|
+
* `writeDevVars({ values: {} })` is the turnstile teardown's regeneration — where refusing turns a
|
|
134
|
+
* regeneration into a failure over a file the run was never going to touch. The split in this module is
|
|
135
|
+
* by *power*, not by file, and a write of nothing has a reader's power: the set it answers is
|
|
136
|
+
* {@link readBootstrapVars}'s, which is the read whose whole argument is that it rewrites nothing.
|
|
137
|
+
*/
|
|
138
|
+
export async function writeBootstrapVars(
|
|
139
|
+
projectDir: string,
|
|
140
|
+
values: Record<string, string>,
|
|
141
|
+
options: StatePathOptions = {},
|
|
142
|
+
): Promise<BootstrapVars> {
|
|
143
|
+
const path = await bootstrapVarsPath(projectDir, options);
|
|
144
|
+
if (path === null) return {};
|
|
145
|
+
if (Object.keys(values).length === 0) return readBootstrapVars(projectDir, options);
|
|
146
|
+
const base = await readDevJson(path);
|
|
147
|
+
const merged: BootstrapVars = { ...(base.document[BOOTSTRAP_VARS_KEY] ?? {}), ...values };
|
|
148
|
+
await ensureDevSecretsDir(base.path);
|
|
149
|
+
await writeFileAtomic(base.path, `${JSON.stringify(withBootstrapVars(base, merged), null, 2)}\n`, {
|
|
150
|
+
mode: 0o600,
|
|
151
|
+
});
|
|
152
|
+
// Unconditionally and after the write, the same as the secrets file's: a `dev.json` an older pithy or an
|
|
153
|
+
// editor created at the umask kept 0644 while holding the dev master key. Narrowing only.
|
|
154
|
+
await tightenMode(base.path);
|
|
155
|
+
return merged;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The document to write: every tenant's key as it was read, with this module's set replaced.
|
|
160
|
+
*
|
|
161
|
+
* **It takes a {@link MergeBase} and that is the whole point (#219).** Only `readMergeBase` mints one, so
|
|
162
|
+
* {@link readBootstrapVars} — which answers `{}` for a file it cannot make sense of, correctly, because
|
|
163
|
+
* it rewrites nothing — cannot be handed to it. The accident that produced five instances of this defect
|
|
164
|
+
* is a type error here rather than another tenant's key gone at the next rename.
|
|
165
|
+
*/
|
|
166
|
+
function withBootstrapVars(base: MergeBase<DevJson>, vars: BootstrapVars): DevJson {
|
|
167
|
+
return { ...base.document, [BOOTSTRAP_VARS_KEY]: vars };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Take names out of this project's bootstrap set — the teardown half, for a value whose resource no
|
|
172
|
+
* longer exists. Returns the set that remains, so a caller regenerating from it never re-reads.
|
|
173
|
+
*
|
|
174
|
+
* A name that is not there is a no-op, and a project with no `dev.json` is not created one. Removing a
|
|
175
|
+
* value here is what makes the next generation drop its line: the generated file is built from the
|
|
176
|
+
* sources rather than edited, so there is no line to delete anywhere else.
|
|
177
|
+
*
|
|
178
|
+
* **That no-op still reads strictly, and its sibling does not — same rule, different answer (#222).** The
|
|
179
|
+
* early return belongs before the read exactly when the *caller's arguments* settle whether anything is
|
|
180
|
+
* written. {@link writeBootstrapVars} knows that from an empty `values`. This one only learns it from the
|
|
181
|
+
* file, and a file nothing could read supports no claim about what is in it — least of all "that name is
|
|
182
|
+
* not there". So the one case its arguments do settle, an empty `names`, returns before the read, and
|
|
183
|
+
* every other case earns its no-op by reading the document properly or refusing.
|
|
184
|
+
*/
|
|
185
|
+
export async function removeBootstrapVars(
|
|
186
|
+
projectDir: string,
|
|
187
|
+
names: readonly string[],
|
|
188
|
+
options: StatePathOptions = {},
|
|
189
|
+
): Promise<BootstrapVars> {
|
|
190
|
+
const path = await bootstrapVarsPath(projectDir, options);
|
|
191
|
+
if (path === null) return {};
|
|
192
|
+
if (names.length === 0) return readBootstrapVars(projectDir, options);
|
|
193
|
+
const base = await readDevJson(path);
|
|
194
|
+
const current: BootstrapVars = base.document[BOOTSTRAP_VARS_KEY] ?? {};
|
|
195
|
+
const remaining = Object.fromEntries(Object.entries(current).filter(([name]) => !names.includes(name)));
|
|
196
|
+
if (Object.keys(remaining).length === Object.keys(current).length) return current;
|
|
197
|
+
await ensureDevSecretsDir(base.path);
|
|
198
|
+
await writeFileAtomic(base.path, `${JSON.stringify(withBootstrapVars(base, remaining), null, 2)}\n`, {
|
|
199
|
+
mode: 0o600,
|
|
200
|
+
});
|
|
201
|
+
await tightenMode(base.path);
|
|
202
|
+
return remaining;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The merge base for a write over `dev.json`: every tenant's key intact, known-good, or a refusal.
|
|
207
|
+
*
|
|
208
|
+
* **The writers' read, and the strict half of this file's split.** `{}` for an absent file and for
|
|
209
|
+
* nothing else. Unopenable, not JSON, parsed to something that is not a document, or a document of
|
|
210
|
+
* something else: four refusals, because the two writers below merge into this answer and rename the
|
|
211
|
+
* result over a file with another tenant in it. Answering `{}` for any of the four is a dev-login
|
|
212
|
+
* preference and this module's own set deleted, with the run reporting a clean write.
|
|
213
|
+
*
|
|
214
|
+
* It used to answer `{}` for a non-object (#209), and then for a file that would not parse and for one
|
|
215
|
+
* that failed its schema (#219) — each corrected once someone noticed that the argument for leniency was
|
|
216
|
+
* {@link readBootstrapVars}'s and had been read as if it belonged to the file. The two are different
|
|
217
|
+
* calls with different powers, and now different types: this one hands back a {@link MergeBase}, which
|
|
218
|
+
* nothing else in this repository can produce.
|
|
219
|
+
*
|
|
220
|
+
* The `ENOENT`-only rule and the other three are `readMergeBase`'s (`../project/readOptionalFile.ts`);
|
|
221
|
+
* the words below are this file's, because "cannot update" is what a read-modify-write over somebody
|
|
222
|
+
* else's tenants has to say.
|
|
223
|
+
*/
|
|
224
|
+
async function readDevJson(path: string): Promise<MergeBase<DevJson>> {
|
|
225
|
+
return readMergeBase(path, DevJson, {
|
|
226
|
+
unreadable: ({ code, cause }) =>
|
|
227
|
+
new ConflictError(
|
|
228
|
+
{
|
|
229
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
230
|
+
action: "Fix the file's permissions, or move it aside, and run the command again.",
|
|
231
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
232
|
+
},
|
|
233
|
+
{ cause },
|
|
234
|
+
),
|
|
235
|
+
unparseable: () =>
|
|
236
|
+
new ConflictError({
|
|
237
|
+
message: `Cannot update ${path}: it is there and is not JSON.`,
|
|
238
|
+
action: "Fix the JSON, or move it aside, and run the command again.",
|
|
239
|
+
// Never the parser's own message: it quotes the line it choked on, and the dev master key is one.
|
|
240
|
+
detail: `dev.json at ${path} did not parse, and every tenant's keys would be replaced by a write from it`,
|
|
241
|
+
}),
|
|
242
|
+
notARecord: ({ found }) =>
|
|
243
|
+
new ConflictError({
|
|
244
|
+
message: `Cannot update ${path}: it holds ${found}, not a document.`,
|
|
245
|
+
action: "Restore it to a JSON object, or move it aside, and run the command again.",
|
|
246
|
+
detail: `dev.json at ${path} parsed to ${found}, and other tenants' keys would be replaced by a write from it`,
|
|
247
|
+
}),
|
|
248
|
+
invalid: ({ at }) =>
|
|
249
|
+
new ConflictError({
|
|
250
|
+
message: `Cannot update ${path}: it is not the document Pithy keeps there.`,
|
|
251
|
+
action: "Fix it, or move it aside, and run the command again.",
|
|
252
|
+
// The key path, so the line can be found. Never the value on it, which may be the dev master key.
|
|
253
|
+
detail: `dev.json at ${path} failed its schema at ${at}`,
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `JSON.parse` that answers `undefined` rather than throwing — this file is hand-edited. */
|
|
259
|
+
function safeJson(source: string): unknown {
|
|
260
|
+
try {
|
|
261
|
+
return JSON.parse(source);
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
7
|
+
import { readOptionalFile } from "../project/readOptionalFile";
|
|
8
|
+
import { writeBootstrapVars } from "./bootstrapVars";
|
|
9
|
+
import { generateDevVars } from "./generate";
|
|
10
|
+
import type { DevSecretsTarget } from "./targets";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one place a value becomes a `.dev.vars` line — and the one place that makes sure the line reaches
|
|
14
|
+
* the Worker that has to read it.
|
|
15
|
+
*
|
|
16
|
+
* Two defects lived here, and both were silent, which is why this is a funnel rather than a fix at each
|
|
17
|
+
* of the four call sites that had them.
|
|
18
|
+
*
|
|
19
|
+
* **Values were written raw.** wrangler parses `.dev.vars` with `dotenv`, whose unquoted value grammar
|
|
20
|
+
* is `[^#\r\n]+` — so `KEY=s3cr3t#tail` hands the Worker `s3cr3t`, with no warning anywhere. A generated
|
|
21
|
+
* key is base64 and never contains a `#`; an OAuth client secret is whatever the provider issued, and
|
|
22
|
+
* this file is where those land. The failure arrives at the first sign-in, as a signature that does not
|
|
23
|
+
* verify, against a value that looks present in every file you would think to check.
|
|
24
|
+
*
|
|
25
|
+
* **And the line went somewhere the Worker does not read.** `pithy dev` spawns wrangler with
|
|
26
|
+
* `cwd: apps/<worker>`, and wrangler loads the `.dev.vars` beside the Worker's own config — so a value
|
|
27
|
+
* written to the project root alone landed in a file nothing reads. That was solved with a symlink into
|
|
28
|
+
* each `apps/<worker>/`, and the symlink produced five defects of its own. It is solved by generation
|
|
29
|
+
* now: see {@link ./generate}, which owns the whole destination side.
|
|
30
|
+
*
|
|
31
|
+
* **The encoding is verified, not reasoned about.** {@link encodeDevVarsValue} tries the three forms
|
|
32
|
+
* dotenv accepts and keeps the first one that survives a round trip through *both* readers — wrangler's
|
|
33
|
+
* and ours. A value no form survives is refused by name. That is deliberately stricter than a rule
|
|
34
|
+
* about which characters are dangerous: the rule would need updating for the shape nobody thought of,
|
|
35
|
+
* and the round trip already covers it.
|
|
36
|
+
*
|
|
37
|
+
* **A refusal is fail-closed, and now structurally.** The binding is the only place a `cf-secrets-store`
|
|
38
|
+
* secret is read from, so a refused value that left the superseded line in place handed the Worker the
|
|
39
|
+
* *previous* secret while every report said the value was replaced. The generated file is built from the
|
|
40
|
+
* sources every run rather than upserted into, so a refused value has no line to leave behind.
|
|
41
|
+
*
|
|
42
|
+
* **What this function still owns is persistence.** A generated file cannot be its own source of truth,
|
|
43
|
+
* so a value written here goes to the machine-local bootstrap store (`./bootstrapVars`) and the Workers'
|
|
44
|
+
* files are regenerated from it. That is what makes a master key minted by `pithy add secrets` survive to
|
|
45
|
+
* the next `pithy dev` without `.dev.vars` accumulating state nobody can regenerate.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** What one value's encoding produced: the bytes to write, or the sentence saying why nothing was. */
|
|
49
|
+
export interface DevVarsEncoding {
|
|
50
|
+
/** The text that goes after `KEY=`, ready to write. `null` when no faithful encoding exists. */
|
|
51
|
+
encoded: string | null;
|
|
52
|
+
/** Why the value cannot be written, naming the secret and never its value. `null` when it can. */
|
|
53
|
+
refused: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `dotenv@16.3.1`'s line grammar, copied verbatim from the parser wrangler ships
|
|
58
|
+
* (`node_modules/wrangler/wrangler-dist/cli.js`, `var LINE = …`).
|
|
59
|
+
*
|
|
60
|
+
* It is exported so a test can assert it still appears in that bundle byte for byte. A copy that drifts
|
|
61
|
+
* from wrangler's is worse than no copy at all: {@link encodeDevVarsValue} verifies every value against
|
|
62
|
+
* it, so a drift would silently bless an encoding the Worker reads differently.
|
|
63
|
+
*/
|
|
64
|
+
export const DOTENV_LINE =
|
|
65
|
+
/(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a `.dev.vars` body the way **wrangler** does — quoted spans, inline `#` comments, and the
|
|
69
|
+
* `\n`/`\r` expansion a double-quoted value gets. This is the reader whose answer the Worker actually
|
|
70
|
+
* receives, so it is the one an encoding has to satisfy.
|
|
71
|
+
*
|
|
72
|
+
* Not a replacement for `parseDevVars`. That one is what pithy's own commands read the file with, and
|
|
73
|
+
* the two disagree — on `#`, on backticks, on escapes. Both are checked, because a value that reads one
|
|
74
|
+
* way in the Worker and another in `pithy doctor` is its own bug.
|
|
75
|
+
*/
|
|
76
|
+
export function parseDotenv(source: string): Record<string, string> {
|
|
77
|
+
const vars: Record<string, string> = {};
|
|
78
|
+
const line = new RegExp(DOTENV_LINE.source, DOTENV_LINE.flags);
|
|
79
|
+
const body = source.toString().replace(/\r\n?/gm, "\n");
|
|
80
|
+
let match: RegExpExecArray | null = line.exec(body);
|
|
81
|
+
while (match !== null) {
|
|
82
|
+
const key = match[1];
|
|
83
|
+
let value = (match[2] ?? "").trim();
|
|
84
|
+
const quote = value[0];
|
|
85
|
+
value = value.replace(/^(['"`])([\s\S]*)\1$/gm, "$2");
|
|
86
|
+
if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
|
|
87
|
+
if (key !== undefined) vars[key] = value;
|
|
88
|
+
match = line.exec(body);
|
|
89
|
+
}
|
|
90
|
+
return vars;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The three forms dotenv accepts, in the order they are tried: as-is, single-quoted, double-quoted. */
|
|
94
|
+
function candidates(value: string): string[] {
|
|
95
|
+
const forms = [value];
|
|
96
|
+
if (!value.includes("'")) forms.push(`'${value}'`);
|
|
97
|
+
if (!value.includes('"')) forms.push(`"${value}"`);
|
|
98
|
+
return forms;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Encode one value for `.dev.vars`, or refuse.
|
|
103
|
+
*
|
|
104
|
+
* A candidate is accepted only when writing `NAME=<candidate>` and reading it back gives the value
|
|
105
|
+
* again — through wrangler's parser *and* through pithy's. The plain form is tried first, so the
|
|
106
|
+
* overwhelming majority of values (base64 keys, JSON envelopes) are written exactly as they always
|
|
107
|
+
* were and no existing file churns.
|
|
108
|
+
*
|
|
109
|
+
* The name is checked by the same round trip rather than by a separate rule: dotenv's key grammar is
|
|
110
|
+
* `[\w.-]+`, so a secret named with a `:` produces a line the Worker never sees at all. Silence is the
|
|
111
|
+
* worst answer available for a secret, so that is a refusal too.
|
|
112
|
+
*/
|
|
113
|
+
export function encodeDevVarsValue(name: string, value: string): DevVarsEncoding {
|
|
114
|
+
for (const candidate of candidates(value)) {
|
|
115
|
+
const line = `${name}=${candidate}\n`;
|
|
116
|
+
if (parseDotenv(line)[name] === value && parseDevVars(line)[name] === value) {
|
|
117
|
+
return { encoded: candidate, refused: null };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
encoded: null,
|
|
122
|
+
refused: `${name} cannot be written to .dev.vars — no quoting survives its value. It belongs in the dev secrets file; see #153.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What {@link writeDevVars} needs. Every seam defaults to the real project. */
|
|
127
|
+
export interface WriteDevVarsOptions {
|
|
128
|
+
/** The project root — owner of `apps/`, of the `.dev.vars.local` files, and of the project's name. */
|
|
129
|
+
projectDir: string;
|
|
130
|
+
/**
|
|
131
|
+
* The values to record in `dev.json`, by variable name. **Only what no registry declares** — a
|
|
132
|
+
* Turnstile sitekey, a machine-local endpoint. A registry secret's value belongs in `secrets.jsonc`,
|
|
133
|
+
* which the generator reads directly (#179). Empty records nothing and regenerates anyway.
|
|
134
|
+
*/
|
|
135
|
+
values: Record<string, string>;
|
|
136
|
+
/** The Worker directories to generate into. Defaults to every discovered Worker with a `wrangler.jsonc`. */
|
|
137
|
+
workerDirs?: string[];
|
|
138
|
+
/** The Workers whose registries decide which secrets are materialised. Defaults to every one composing `secrets`. */
|
|
139
|
+
targets?: DevSecretsTarget[];
|
|
140
|
+
/** Where the Pithy config directory is. Defaults to the real one; a seam so a test writes its own. */
|
|
141
|
+
paths?: StatePathOptions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What one write did. Every list is sorted, so two runs of the same state read the same. */
|
|
145
|
+
export interface WriteDevVarsResult {
|
|
146
|
+
/** The variable names actually recorded — the ones an encoding survives. */
|
|
147
|
+
written: string[];
|
|
148
|
+
/**
|
|
149
|
+
* One sentence per value or Worker directory that did not get one: a value no quoting survives, a
|
|
150
|
+
* `.dev.vars` pithy did not generate, a directory this project may not write into. Never a value.
|
|
151
|
+
*/
|
|
152
|
+
refused: string[];
|
|
153
|
+
/** Worker directories whose `.dev.vars` this run wrote. */
|
|
154
|
+
generated: string[];
|
|
155
|
+
/** Worker directories whose `.dev.vars` already held exactly these bytes. No bytes written, mtime intact. */
|
|
156
|
+
unchanged: string[];
|
|
157
|
+
/** Worker directories whose `.dev.vars` was a symlink from the old shared-file design, now a real file. */
|
|
158
|
+
relinked: string[];
|
|
159
|
+
/** Every variable name the generated files carry, sorted. Names only — never a value, anywhere. */
|
|
160
|
+
names: string[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Record values in the machine-local bootstrap store, then regenerate every Worker's `.dev.vars` from it.
|
|
165
|
+
*
|
|
166
|
+
* Mode `0600` on every file written, the same as the dev secrets file: this one holds the dev master key
|
|
167
|
+
* and every `cf-secrets-store` value, and the umask default is world-readable.
|
|
168
|
+
*/
|
|
169
|
+
export async function writeDevVars(options: WriteDevVarsOptions): Promise<WriteDevVarsResult> {
|
|
170
|
+
const written: string[] = [];
|
|
171
|
+
const refused: string[] = [];
|
|
172
|
+
const encoded: Record<string, string> = {};
|
|
173
|
+
for (const name of Object.keys(options.values).sort()) {
|
|
174
|
+
const value = options.values[name];
|
|
175
|
+
if (value === undefined) continue;
|
|
176
|
+
// Encoded here only to decide whether the value can be delivered at all. The bootstrap store keeps
|
|
177
|
+
// the plain value — quoting is `.dev.vars` grammar, and a quoted value round-tripped through a JSON
|
|
178
|
+
// file would acquire a second layer of it on the next generation.
|
|
179
|
+
const result = encodeDevVarsValue(name, value);
|
|
180
|
+
if (result.encoded === null) {
|
|
181
|
+
if (result.refused !== null) refused.push(result.refused);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
encoded[name] = value;
|
|
185
|
+
written.push(name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await writeBootstrapVars(options.projectDir, encoded, options.paths ?? {});
|
|
189
|
+
// Regenerated from the **sources**, never from what was just recorded. A generator handed a value set
|
|
190
|
+
// by its caller is a generator that can disagree with the files it claims to read — which is exactly
|
|
191
|
+
// how a `cf-secrets-store` secret came to reach a Worker one `pithy seed` after it was rotated (#179).
|
|
192
|
+
const generated = await generateDevVars({
|
|
193
|
+
projectDir: options.projectDir,
|
|
194
|
+
...(options.workerDirs !== undefined ? { workerDirs: options.workerDirs } : {}),
|
|
195
|
+
...(options.targets !== undefined ? { targets: options.targets } : {}),
|
|
196
|
+
...(options.paths !== undefined ? { paths: options.paths } : {}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
written,
|
|
201
|
+
refused: [...refused, ...generated.refused].sort(),
|
|
202
|
+
generated: generated.generated,
|
|
203
|
+
unchanged: generated.unchanged,
|
|
204
|
+
relinked: generated.relinked,
|
|
205
|
+
names: generated.names,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The file's bytes, or `null` when there is no file — and **only** when there is no file.
|
|
211
|
+
*
|
|
212
|
+
* The rule and its three producers now live in {@link readOptionalFile} (`../project/`). What stays here
|
|
213
|
+
* is what is `.dev.vars`-shaped: the sentence an adopter reads, the mode it names, and *why* absence had
|
|
214
|
+
* to stop meaning "unreadable" for this file in particular. Reading `EACCES` or `EIO` as "empty" meant
|
|
215
|
+
* the next content was built from an empty base and renamed over a file full of values this process never
|
|
216
|
+
* saw — the adopter's `CLOUDFLARE_API_TOKEN` and every other line, gone, with the run reporting a clean
|
|
217
|
+
* write. The same defect `readSource` was written to end for the dev secrets file, in the file beside it,
|
|
218
|
+
* twice over.
|
|
219
|
+
*
|
|
220
|
+
* The wrapped error carries the node error as `cause`. Its message is a path and an errno, never a line
|
|
221
|
+
* of the file.
|
|
222
|
+
*
|
|
223
|
+
* **Exported because reading this file honestly is not only the writer's problem.** `pithy add` asks the
|
|
224
|
+
* same question — is this secret already here? — and its own `.catch(() => "")` answered "no" for an
|
|
225
|
+
* unreadable file, so `add` minted a second value and the project held two under one name, one in each
|
|
226
|
+
* file. Three readers of one file is how one of them keeps getting it wrong.
|
|
227
|
+
*/
|
|
228
|
+
export async function readDevVarsSource(path: string): Promise<string | null> {
|
|
229
|
+
return readOptionalFile(path, {
|
|
230
|
+
unreadable: ({ code, cause }) =>
|
|
231
|
+
new InternalError(
|
|
232
|
+
{
|
|
233
|
+
message: ".dev.vars is there and could not be read.",
|
|
234
|
+
action: `Check ${path} and its permissions. It should be a file, mode 600.`,
|
|
235
|
+
detail: `.dev.vars '${path}' failed to read: ${code ?? "unknown error"}`,
|
|
236
|
+
},
|
|
237
|
+
{ cause },
|
|
238
|
+
),
|
|
239
|
+
});
|
|
240
|
+
}
|