@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,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DevSecretsSeedReport } from "./seed";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a seeding run says out loud — shared by `pithy add`, `pithy seed`, and `pithy dev`, so the same
|
|
8
|
+
* state reads identically whichever command reached it.
|
|
9
|
+
*
|
|
10
|
+
* **Silence is the default.** A run that seeded nothing new says nothing: `pithy dev` seeds on every
|
|
11
|
+
* start, and a line per start about secrets that have not changed since last week is noise that trains
|
|
12
|
+
* people to skip the block where the one real problem eventually appears. Only what this run *changed*,
|
|
13
|
+
* and what stopped it from running at all, gets a line.
|
|
14
|
+
*
|
|
15
|
+
* **`missing` and `undeclared` are deliberately not among them.** Both are standing states rather than
|
|
16
|
+
* run outcomes, and both were wrong here for the same reason twice over. `missing`: auth declares four
|
|
17
|
+
* OAuth credential pairs and almost every project sets none, so naming them put four names in front of
|
|
18
|
+
* every `pithy dev` and every `pithy seed`, forever, about nothing that had changed. `undeclared`: this
|
|
19
|
+
* runs inside `pithy add`, which has just rewritten `pithy.config.ts` — and the process is still holding
|
|
20
|
+
* the module it imported before that write, so `pithy add auth` reported the value it had itself just
|
|
21
|
+
* minted as one no capability declares. A snapshot taken mid-change is not a standing state.
|
|
22
|
+
* `pithy doctor` loads the config fresh, in its own process, and is where both of those live.
|
|
23
|
+
*
|
|
24
|
+
* **A value never appears here.** Names only — these lines reach a terminal scrollback and `logs/dev.log`.
|
|
25
|
+
*/
|
|
26
|
+
export function renderDevSecretsNotes(report: DevSecretsSeedReport): string[] {
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
// The path, not the file's name. It is outside the checkout now (#156), so "minted into
|
|
29
|
+
// secrets.jsonc" names nothing the reader can open — and a project whose name collides with
|
|
30
|
+
// another's is only visible from the whole path.
|
|
31
|
+
if (report.minted.length > 0) {
|
|
32
|
+
lines.push(`Minted ${list(report.minted)} into ${report.path ?? "the dev secrets file"}. Local only.`);
|
|
33
|
+
}
|
|
34
|
+
// Before the mints and the seeds, because it is about bytes that were already there. An adopter
|
|
35
|
+
// reading this block wants to know what changed in the file they maintain before what was added to it.
|
|
36
|
+
if ((report.migrated ?? []).length > 0) {
|
|
37
|
+
lines.push(
|
|
38
|
+
`Restated ${list(report.migrated ?? [])} in ${report.path ?? "the dev secrets file"}: the file states the value its destination receives.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (report.seeded.length > 0) {
|
|
42
|
+
lines.push(`Seeded ${list(report.seeded)} into the local secrets store.`);
|
|
43
|
+
}
|
|
44
|
+
for (const { worker, reason } of report.skipped) {
|
|
45
|
+
lines.push(`${worker}: secrets not seeded. ${reason}`);
|
|
46
|
+
}
|
|
47
|
+
// These describe a `cf-secrets-store` value that is in the file and still will not reach a Worker —
|
|
48
|
+
// its binding is the only place it is ever read from. They are run outcomes, not standing states: a
|
|
49
|
+
// project with none hears nothing, and a project with any hears it every run until it is fixed.
|
|
50
|
+
lines.push(
|
|
51
|
+
...renderDevVarsNotes({
|
|
52
|
+
refused: report.devVarsRefused ?? [],
|
|
53
|
+
...(report.relinked !== undefined ? { relinked: report.relinked } : {}),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The two ways a generated `.dev.vars` is worth a sentence. Structurally a {@link WriteDevVarsResult}. */
|
|
60
|
+
export interface DevVarsDelivery {
|
|
61
|
+
/**
|
|
62
|
+
* One sentence per value or Worker directory that did not get one — a value no quoting survives, a
|
|
63
|
+
* `.dev.vars` pithy did not generate, a directory it may not write into. Already actionable.
|
|
64
|
+
*/
|
|
65
|
+
refused: readonly string[];
|
|
66
|
+
/** Worker directories whose `.dev.vars` was a symlink from the old shared-file design, now a real file. */
|
|
67
|
+
relinked?: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What one `.dev.vars` generation says out loud.
|
|
72
|
+
*
|
|
73
|
+
* **Shared, because a caller that reads only `refused` puts the defect back.** `writeDevVars` grew a
|
|
74
|
+
* delivery report to end a run claiming a value had arrived when it had not; `pithy add`'s two direct
|
|
75
|
+
* calls then took `.refused` off the result and dropped the rest, so `pithy add secrets` printed "Minted
|
|
76
|
+
* a dev master key" while the Worker answered `Missing required bindings`. One renderer means the next
|
|
77
|
+
* caller gets every list by taking the only thing there is to take.
|
|
78
|
+
*
|
|
79
|
+
* **Silence for the ordinary run.** `generated` and `unchanged` say nothing: a file rewritten with the
|
|
80
|
+
* same three bindings on every `pithy dev` is not news, and a line per Worker per start is how a block
|
|
81
|
+
* stops being read.
|
|
82
|
+
*
|
|
83
|
+
* A value never appears here. Names and directories only — these lines reach a terminal scrollback.
|
|
84
|
+
*/
|
|
85
|
+
export function renderDevVarsNotes(delivery: DevVarsDelivery): string[] {
|
|
86
|
+
const lines = [...delivery.refused];
|
|
87
|
+
for (const dir of delivery.relinked ?? []) {
|
|
88
|
+
lines.push(
|
|
89
|
+
`${dir}/.dev.vars was a symlink at the project's shared file. It is a generated file now — put anything you kept in that shared file into .dev.vars.local.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `a`, `a and b`, `a, b and c` — a sentence, not a JSON array. */
|
|
96
|
+
function list(names: readonly string[]): string {
|
|
97
|
+
if (names.length <= 1) return names.join("");
|
|
98
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
|
|
7
|
+
import { migrateDevSecrets, mintMissingDevSecrets, seedDevSecrets } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
|
|
8
|
+
import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
9
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
10
|
+
import { writeDevVars } from "./devVars";
|
|
11
|
+
import { readDevSecrets, writeDevSecrets } from "./file";
|
|
12
|
+
import { resolveDevSecretsFile } from "./location";
|
|
13
|
+
import {
|
|
14
|
+
type DevSecretsStoreHandle,
|
|
15
|
+
localDevStorePath,
|
|
16
|
+
type OpenDevSecretsStoreOptions,
|
|
17
|
+
openDevSecretsStore,
|
|
18
|
+
} from "./store";
|
|
19
|
+
import { type DevSecretsTarget, mergedSecretRegistry, resolveDevSecretsTargets } from "./targets";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `pithy seed`'s dev-secrets half: take `<config>/<project>/secrets.jsonc`, mint what is missing and
|
|
23
|
+
* generatable, and put every declared value where its registry entry says it belongs. The idempotent
|
|
24
|
+
* workhorse `pithy add` and `pithy dev` both call, so there is one seeding path and not three that drift.
|
|
25
|
+
*
|
|
26
|
+
* **The input moved out of the checkout; the destinations did not (#156).** The file is resolved from
|
|
27
|
+
* the project's *name*, so every worktree of one project seeds from one file with no setup step — and
|
|
28
|
+
* the local `SECRETS` store, `.dev.vars`, and `.wrangler/state` are all still the project directory's.
|
|
29
|
+
*
|
|
30
|
+
* **The registry decides the destination, per Worker.** Capabilities are per-Worker, so a registry is
|
|
31
|
+
* too, and a `d1` secret goes into *that* Worker's local `SECRETS` store. The file is project-wide,
|
|
32
|
+
* because a secret name is the join key everywhere and two Workers sharing a name share a value.
|
|
33
|
+
*
|
|
34
|
+
* **Nothing here is fatal for a project that has no secrets.** A Worker that never composed the
|
|
35
|
+
* capability contributes no registry; one whose store cannot be opened contributes a reason. Both come
|
|
36
|
+
* back in the report for the caller to print. `pithy dev` must start a project whose secrets are not
|
|
37
|
+
* wired yet — refusing to would make an unrelated capability's missing binding stop every Worker.
|
|
38
|
+
*
|
|
39
|
+
* **`.dev.vars` no longer carries application secrets (#153).** #149 had to write every seeded value
|
|
40
|
+
* there as well, because `secretsStore`'s dev branch resolved every secret from its injected binding
|
|
41
|
+
* whatever its backend — so a `d1` value that only reached the local `SECRETS` store reached nowhere dev
|
|
42
|
+
* looked. Dev now routes by backend exactly as deployed does and reads the row this seeder writes, so the
|
|
43
|
+
* dual-write is gone and `.dev.vars` is back to what wrangler says it is: env bindings, `UPPER_SNAKE`,
|
|
44
|
+
* one namespace. The only thing still written there is a `cf-secrets-store` secret, which belongs there
|
|
45
|
+
* permanently — there is no local Secrets Store, and the binding is the only place a Worker can read it.
|
|
46
|
+
*
|
|
47
|
+
* **A registry secret sitting in an adopter's `.dev.vars` is now inert, and this run treats it as
|
|
48
|
+
* absent.** It used to be dev's live value, so minting beside it produced two values with nothing to say
|
|
49
|
+
* which signed what. It signs nothing now. So a mintable secret is minted and seeded — a project that
|
|
50
|
+
* upgrades is not left with a Worker that cannot resolve its session key — and the stranded line is
|
|
51
|
+
* `pithy doctor`'s to name, every run, until it is deleted. Nothing here rewrites their file.
|
|
52
|
+
*
|
|
53
|
+
* **The `cf-secrets-store` write goes through {@link writeDevVars}, which is what makes it arrive.**
|
|
54
|
+
* Writing the project root's `.dev.vars` was never the same as reaching the Worker: `pithy dev` runs
|
|
55
|
+
* wrangler with `cwd: apps/<worker>`, and wrangler loads the file beside the Worker's own config. Each
|
|
56
|
+
* Worker's file is **generated** now (#154), from the machine-local bootstrap store and the
|
|
57
|
+
* `.dev.vars.local` overrides — so there is no link to wire, dangle, delete, or detach, and this seeding
|
|
58
|
+
* run is one of the two commands that regenerate.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** What one seeding run did. Every list is sorted, so two runs of the same state read the same. */
|
|
62
|
+
export interface DevSecretsSeedReport {
|
|
63
|
+
/**
|
|
64
|
+
* The absolute path of the secrets file this run read and minted into.
|
|
65
|
+
*
|
|
66
|
+
* In the report because the file is outside the checkout (#156) and nothing else in the run names
|
|
67
|
+
* it: "minted a dev auth-session-secret" is not actionable if the reader cannot open what it landed
|
|
68
|
+
* in. Optional so report doubles that assert only on `seeded` or `skipped` stay valid.
|
|
69
|
+
*/
|
|
70
|
+
path?: string;
|
|
71
|
+
/** `d1` secrets written this run — new, or changed in the file since the last run. */
|
|
72
|
+
seeded: string[];
|
|
73
|
+
/** `d1` secrets already stored with the value the file states. Not rewritten. */
|
|
74
|
+
unchanged: string[];
|
|
75
|
+
/** Values minted this run and written back into the secrets file. */
|
|
76
|
+
minted: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Secrets whose entry this run **restated as the payload its destination receives** (#323) — the
|
|
79
|
+
* upgrade off the old wrapped shape, performed in place so no operator hand-edits a master key.
|
|
80
|
+
*
|
|
81
|
+
* Empty on every run after the first, and on every project created since. Never silent while it is
|
|
82
|
+
* not: the bytes of the one file an adopter hand-maintains changed, and a rewrite nobody is told
|
|
83
|
+
* about is indistinguishable from corruption the next time they open it.
|
|
84
|
+
*
|
|
85
|
+
* Optional for the same reason {@link DevSecretsSeedReport.path} is: a report double that asserts
|
|
86
|
+
* only on `seeded` or `skipped` stays valid.
|
|
87
|
+
*/
|
|
88
|
+
migrated?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* Secrets written into `.dev.vars` this run — `cf-secrets-store` ones, and only those. There is no
|
|
91
|
+
* local Secrets Store, so the binding is the only place a Worker can read one from. A `d1` secret is
|
|
92
|
+
* never here: since #153 dev reads its seeded row, the same as deployed.
|
|
93
|
+
*/
|
|
94
|
+
devVars: string[];
|
|
95
|
+
/** Declared secrets with no value and nothing honest to mint. The adopter supplies these. */
|
|
96
|
+
missing: string[];
|
|
97
|
+
/** Names in the file that no Worker's registry declares. Reported, never fatal. */
|
|
98
|
+
undeclared: string[];
|
|
99
|
+
/** Workers whose local store could not be opened, and the one thing each needs. */
|
|
100
|
+
skipped: { worker: string; reason: string }[];
|
|
101
|
+
/**
|
|
102
|
+
* One sentence per value no `.dev.vars` quoting survives — see `encodeDevVarsValue`. Never a value.
|
|
103
|
+
* Only a `cf-secrets-store` secret can be refused now, and the binding is the only place that one is
|
|
104
|
+
* ever read from, so a refusal is a Worker without it. Any superseded line went with it, so the
|
|
105
|
+
* sentence describes a Worker with *no* value rather than one quietly on the old one.
|
|
106
|
+
*/
|
|
107
|
+
devVarsRefused?: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Worker directories whose `.dev.vars` was a symlink from the old shared-file design and is now a
|
|
110
|
+
* generated file (#154). A link holds no content, so nothing was lost — but which secrets a Worker
|
|
111
|
+
* runs with did change, so it is never silent.
|
|
112
|
+
*/
|
|
113
|
+
relinked?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What {@link seedProjectDevSecrets} needs. Both seams default to the real project. */
|
|
117
|
+
export interface SeedProjectDevSecretsOptions {
|
|
118
|
+
/** The project root — owner of `.dev.vars` and the `.wrangler/state` stores. Not of the secrets file. */
|
|
119
|
+
projectDir: string;
|
|
120
|
+
/**
|
|
121
|
+
* Where the Pithy config directory is, for {@link resolveDevSecretsFile}. Defaults to the real one:
|
|
122
|
+
* `$PITHY_CONFIG_DIR`, else the platform's. A seam so a test never writes to the operator's own file.
|
|
123
|
+
*/
|
|
124
|
+
paths?: StatePathOptions;
|
|
125
|
+
/** The Workers to seed for. Defaults to every Worker in `apps/` that composes the secrets capability. */
|
|
126
|
+
targets?: DevSecretsTarget[];
|
|
127
|
+
/** Seam: open one Worker's local store. Defaults to the real Miniflare-backed one. */
|
|
128
|
+
openStore?: (options: OpenDevSecretsStoreOptions) => Promise<DevSecretsStoreHandle>;
|
|
129
|
+
/**
|
|
130
|
+
* Re-import every `pithy.config.ts` before deriving targets — see {@link DevSecretsTargetsOptions}.
|
|
131
|
+
* Ignored when `targets` is supplied, which is already an answer about the composition.
|
|
132
|
+
*/
|
|
133
|
+
reload?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* **Unsayable on purpose (#159).** No environment, ever, by any spelling.
|
|
136
|
+
*
|
|
137
|
+
* The dev secrets file holds minted random dev values. Seeding it into staging or production would not
|
|
138
|
+
* set some secrets — it would rotate every one at once: every session invalidated, every signed link
|
|
139
|
+
* broken, every OAuth credential replaced with a value the provider has never seen, and no undo,
|
|
140
|
+
* because the values it overwrote were the only copies. A `--force` does not make that safe, it makes
|
|
141
|
+
* it reachable. Production secrets are set one at a time by `pithy secrets provision` and
|
|
142
|
+
* `pithy secrets set`, which know they are touching a live environment.
|
|
143
|
+
*
|
|
144
|
+
* `never` is the strong half of the guarantee: a caller cannot pass the wrong environment because it
|
|
145
|
+
* cannot pass one at all. {@link assertLocalDevStore} is the other half, for the destination a caller
|
|
146
|
+
* *can* still get wrong.
|
|
147
|
+
*/
|
|
148
|
+
env?: never;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Seed the project's dev secrets. Throws only when the file itself is malformed — that is the boundary. */
|
|
152
|
+
export async function seedProjectDevSecrets(options: SeedProjectDevSecretsOptions): Promise<DevSecretsSeedReport> {
|
|
153
|
+
const projectDir = options.projectDir;
|
|
154
|
+
const openStore = options.openStore ?? openDevSecretsStore;
|
|
155
|
+
// Resolved through the form that cannot drop a failure (#199). The unresolvable half is deliberately
|
|
156
|
+
// not re-reported from here: `pithy seed` fails outright on an unloadable config long before it
|
|
157
|
+
// reaches this function, and inside `pithy dev` the generation step has already said it in one
|
|
158
|
+
// sentence. Saying it twice, in two blocks, is the correlation problem the sentence exists to end.
|
|
159
|
+
const targets =
|
|
160
|
+
options.targets ?? (await resolveDevSecretsTargets(projectDir, { reload: options.reload === true })).targets;
|
|
161
|
+
|
|
162
|
+
const seeded = new Set<string>();
|
|
163
|
+
const unchanged = new Set<string>();
|
|
164
|
+
const missing = new Set<string>();
|
|
165
|
+
const skipped: { worker: string; reason: string }[] = [];
|
|
166
|
+
const declared = new Set<string>();
|
|
167
|
+
// Resolved once, from the project's name rather than its directory (#156) — so every worktree of one
|
|
168
|
+
// project seeds from one file, with no setup step and nothing linked into the checkout. It is also
|
|
169
|
+
// what every note and every error this run raises names, because nothing in the project points at it.
|
|
170
|
+
const path = await resolveDevSecretsFile(projectDir, options.paths ?? {});
|
|
171
|
+
// The file is read once and carried across Workers. Two Workers that declare one secret must mint it
|
|
172
|
+
// once: the second sees the first's value in this object, and `seedDevSecrets` never mints over one.
|
|
173
|
+
const file = await readDevSecrets(path);
|
|
174
|
+
// **Before a value is read, minted or seeded.** The reader accepts both shapes, so nothing depends on
|
|
175
|
+
// this having run — that is what makes it safe to do here rather than in a migration command nobody
|
|
176
|
+
// runs. What it buys is that the file stops holding a shape the writer no longer produces, which is
|
|
177
|
+
// the state two readers reported as corruption (#323).
|
|
178
|
+
const migrated = await restatePayloads(path, file, targets);
|
|
179
|
+
|
|
180
|
+
const minted = new Set<string>();
|
|
181
|
+
|
|
182
|
+
for (const target of targets) {
|
|
183
|
+
for (const name of Object.keys(target.registry)) declared.add(name);
|
|
184
|
+
|
|
185
|
+
const handle = await openStore({
|
|
186
|
+
projectDir,
|
|
187
|
+
workerDir: target.dir,
|
|
188
|
+
worker: target.name,
|
|
189
|
+
...(options.paths !== undefined ? { paths: options.paths } : {}),
|
|
190
|
+
});
|
|
191
|
+
if (!handle.ready) {
|
|
192
|
+
skipped.push({ worker: target.name, reason: handle.reason });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
// Before a byte is minted or stored. The destination is what makes this a dev seeding run — not
|
|
197
|
+
// the caller's word for it, and not a flag anywhere upstream.
|
|
198
|
+
assertLocalDevStore(projectDir, target.name, handle.persistPath);
|
|
199
|
+
// **Persist before storing.** A minted value written to D1 before it reaches the secrets
|
|
200
|
+
// file is a row nothing explains: the next run finds the file still without it,
|
|
201
|
+
// mints a *different* value, and overwrites the row — for a session secret, every live session
|
|
202
|
+
// invalidated on every `pithy dev`, for as long as the file write keeps failing. And a failing
|
|
203
|
+
// file write is exactly the state that produced it. This way a failed write costs a value that
|
|
204
|
+
// never existed anywhere, and the store is left holding the last one that did.
|
|
205
|
+
const fresh = mintMissingDevSecrets(file, target.registry);
|
|
206
|
+
for (const name of await writeDevSecrets(path, fresh)) {
|
|
207
|
+
const envelope = fresh[name];
|
|
208
|
+
if (!envelope) continue;
|
|
209
|
+
file[name] = envelope;
|
|
210
|
+
minted.add(name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Nothing minted here is seeded unless it landed. Both `file` and the registry are narrowed, so
|
|
214
|
+
// `seedDevSecrets` has nothing left to mint and this is the only place a mint can happen.
|
|
215
|
+
const result = await seedDevSecrets({
|
|
216
|
+
file,
|
|
217
|
+
registry: seedable(target.registry, file),
|
|
218
|
+
store: handle.store,
|
|
219
|
+
path,
|
|
220
|
+
});
|
|
221
|
+
for (const name of result.seeded) seeded.add(name);
|
|
222
|
+
for (const name of result.unchanged) unchanged.add(name);
|
|
223
|
+
for (const name of result.missing) missing.add(name);
|
|
224
|
+
// `result.devVars` is deliberately not consumed here any more (#179). A `cf-secrets-store` value's
|
|
225
|
+
// destination is the generated `.dev.vars`, and the generator reads `secrets.jsonc` itself — the
|
|
226
|
+
// same file this loop has open. Copying it out through this run was what made the dev secrets file
|
|
227
|
+
// stop being the source of the values a Worker receives.
|
|
228
|
+
} finally {
|
|
229
|
+
await handle.dispose();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Regenerate every Worker's `.dev.vars` from the sources, carrying **these** targets — the composition
|
|
234
|
+
// this run resolved, which for `pithy add` is the reloaded one. Nothing is recorded on the way: the
|
|
235
|
+
// values are in `secrets.jsonc`, which the generator reads.
|
|
236
|
+
//
|
|
237
|
+
// It runs even when there was nothing to seed, which is what makes a fresh clone's `pithy dev` work
|
|
238
|
+
// with no postinstall and nothing to remember (#139, closed by removal).
|
|
239
|
+
const wrote = await writeDevVars({
|
|
240
|
+
projectDir,
|
|
241
|
+
values: {},
|
|
242
|
+
targets,
|
|
243
|
+
...(options.paths !== undefined ? { paths: options.paths } : {}),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// No target is no registry, and no registry is nothing to judge a name against. Calling every secret
|
|
247
|
+
// in the file undeclared because this project has not composed `secrets` yet is a false statement, and
|
|
248
|
+
// `pithy add auth` made it about the value it had just minted itself.
|
|
249
|
+
const undeclared = targets.length === 0 ? [] : Object.keys(file).filter((name) => !declared.has(name));
|
|
250
|
+
return {
|
|
251
|
+
path,
|
|
252
|
+
seeded: sorted(seeded),
|
|
253
|
+
unchanged: sorted(unchanged),
|
|
254
|
+
// What the write actually landed, never what was minted into memory. A refused write minted values
|
|
255
|
+
// that reached no file, and reporting them as minted is how a command claims a value it does not have.
|
|
256
|
+
minted: sorted(minted),
|
|
257
|
+
migrated,
|
|
258
|
+
// What the generated files actually carry, never what was handed to a writer — a value no quoting
|
|
259
|
+
// survives is refused, and reporting it as written is how a command claims a binding the Worker does
|
|
260
|
+
// not have. Narrowed to this project's `cf-secrets-store` secrets, which is what this field means.
|
|
261
|
+
devVars: wrote.names.filter((name) => isBindingSecret(name, targets)),
|
|
262
|
+
// A secret one Worker cannot mint may be another's to seed. Only the ones nothing supplied are missing.
|
|
263
|
+
missing: sorted(missing).filter((name) => !seeded.has(name) && !unchanged.has(name)),
|
|
264
|
+
undeclared: undeclared.sort(),
|
|
265
|
+
skipped,
|
|
266
|
+
devVarsRefused: wrote.refused,
|
|
267
|
+
relinked: wrote.relinked,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Whether any target's registry declares `name` as a secret a Worker reads from a `.dev.vars` binding. */
|
|
272
|
+
function isBindingSecret(name: string, targets: readonly DevSecretsTarget[]): boolean {
|
|
273
|
+
return targets.some(
|
|
274
|
+
(target) => Object.hasOwn(target.registry, name) && target.registry[name]?.backend === "cf-secrets-store",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Refuse any store that is not this project's own local dev store (#159).
|
|
280
|
+
*
|
|
281
|
+
* **The rule lives here rather than at a call site.** `commands/seed.ts` has guarded it correctly since
|
|
282
|
+
* the day it was written — `env === "dev" && !dryRun` — and that is one caller out of six. Four defect
|
|
283
|
+
* classes in this branch each had three or more producers, every one because the rule was enforced where
|
|
284
|
+
* the thing was called instead of inside the thing being called. This one's payload is every live secret
|
|
285
|
+
* in a production environment, rotated at once, with no copy of what it overwrote.
|
|
286
|
+
*
|
|
287
|
+
* **The destination is asserted, not the intent.** A parameter saying `dev` is a claim; where the rows
|
|
288
|
+
* land is a fact. `openDevSecretsStore` opens Miniflare over {@link localDevStorePath} and reports that
|
|
289
|
+
* path, so a handle bound to a remote D1 — through the `openStore` seam, or a future one — cannot pass.
|
|
290
|
+
*
|
|
291
|
+
* **And an unresolvable destination refuses.** A handle with no path at all is not "probably fine": the
|
|
292
|
+
* permissive default is the whole bug this closes. `undefined` is in the signature and not in the type
|
|
293
|
+
* because the type already forbids it — this is what answers a caller that came from outside TypeScript.
|
|
294
|
+
*/
|
|
295
|
+
function assertLocalDevStore(projectDir: string, worker: string, persistPath: string | undefined): void {
|
|
296
|
+
const expected = localDevStorePath(projectDir);
|
|
297
|
+
if (persistPath !== undefined && resolve(persistPath) === resolve(expected)) return;
|
|
298
|
+
throw new ConflictError({
|
|
299
|
+
message: `Refusing to seed dev secrets for ${worker}: that is not this project's local dev store.`,
|
|
300
|
+
action:
|
|
301
|
+
"Dev secrets are local only. A deployed environment gets its secrets from pithy secrets provision and pithy secrets set, one at a time.",
|
|
302
|
+
detail: `expected the local dev store at '${expected}'; the handle named ${persistPath === undefined ? "no path at all" : `'${persistPath}'`}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The registry minus every mintable secret the file still does not carry — the entries whose write was
|
|
308
|
+
* refused. Dropping them is what stops `seedDevSecrets` from minting a second value and storing it: a
|
|
309
|
+
* row whose value exists in no file is the one outcome minting-before-persisting produced.
|
|
310
|
+
*
|
|
311
|
+
* A no-op on every ordinary run, where the write landed and every mintable name is in the file.
|
|
312
|
+
*/
|
|
313
|
+
function seedable(registry: SecretRegistry, file: DevSecretsFile): SecretRegistry {
|
|
314
|
+
const entries = Object.entries(registry).filter(
|
|
315
|
+
([name, entry]) => !entry.devValue || entry.keyed || Object.hasOwn(file, name),
|
|
316
|
+
);
|
|
317
|
+
return Object.fromEntries(entries) as SecretRegistry;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Rewrite every entry the file still states in the old wrapped shape, and answer which ones moved.
|
|
322
|
+
*
|
|
323
|
+
* **In place, and without an operator hand-editing anything (#323).** The one file an adopter maintains
|
|
324
|
+
* holds their master key; "open it and take a layer off" is an instruction with a way to get it wrong,
|
|
325
|
+
* on the value whose loss orphans every secret encrypted under it.
|
|
326
|
+
*
|
|
327
|
+
* `replace: true` because the name is already there — this is the one write that is meant to change a
|
|
328
|
+
* value already in the file, and it changes only its shape. An entry whose bytes would not change
|
|
329
|
+
* writes nothing, so a migrated project's `pithy dev` rewrites no file and reports no migration.
|
|
330
|
+
*/
|
|
331
|
+
async function restatePayloads(path: string, file: DevSecretsFile, targets: DevSecretsTarget[]): Promise<string[]> {
|
|
332
|
+
const restated = migrateDevSecrets(file, mergedSecretRegistry(targets));
|
|
333
|
+
if (Object.keys(restated).length === 0) return [];
|
|
334
|
+
const wrote = await writeDevSecrets(path, restated, { replace: true });
|
|
335
|
+
// Only what landed. The in-memory file is what the rest of this run seeds from, and carrying a value
|
|
336
|
+
// the write refused would seed a shape no file explains — the same rule the mint above follows.
|
|
337
|
+
for (const name of wrote) file[name] = restated[name];
|
|
338
|
+
return wrote.sort();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A set as a sorted array — every list in the report is ordered, so a run reads the same twice. */
|
|
342
|
+
function sorted(names: Set<string>): string[] {
|
|
343
|
+
return [...names].sort();
|
|
344
|
+
}
|