@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,407 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { relative } from "node:path";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { ProvisionScope } from "@pithy-sh/core/src/naming/provisionScope";
|
|
8
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
9
|
+
import { availableManifests } from "../capabilities/manifests";
|
|
10
|
+
import { honoredDeclineNames } from "../capabilities/reconcile";
|
|
11
|
+
import { provisionableBindings, serviceBindings } from "../feature/bindings";
|
|
12
|
+
import type { FeatureResource } from "../feature/manifest";
|
|
13
|
+
import { migrateProject } from "../migrations/run";
|
|
14
|
+
import { loadProject, loadProjectCloudflare, requireProjectName, type WorkerConfig } from "../project/config";
|
|
15
|
+
import { resolveWorkers } from "../project/workerScope";
|
|
16
|
+
import { seedProject } from "../seed/run";
|
|
17
|
+
import { AUDIT_RESOURCE_TYPE, ProvisionAuditActions, type ResourceProvisioners } from "./resources";
|
|
18
|
+
import type { SecretStoreBinding } from "./secretBindings";
|
|
19
|
+
import { applyProvisionedEnv, type ServiceEntry } from "./wranglerEnv";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* **Standing up one environment's own Cloudflare resources — for any environment a project has.**
|
|
23
|
+
*
|
|
24
|
+
* This was written for ephemeral feature environments and was never generalized, which is the single
|
|
25
|
+
* cause of three reported defects: a declared `staging` got no resources at all (#240), a feature got
|
|
26
|
+
* every resource except its secrets (#239), and a deployed Worker got no Secrets Store bindings (#238).
|
|
27
|
+
* The machinery was always general — it resolves the Worker set, provisions one resource per binding
|
|
28
|
+
* name, writes ids into each Worker's own config, then migrates and seeds. Only the *namer* was
|
|
29
|
+
* feature-shaped, and only the command surface was missing.
|
|
30
|
+
*
|
|
31
|
+
* So the environment is not a parameter here. A {@link ProvisionScope} is, and it carries both the names
|
|
32
|
+
* and the stanza they are written into — see `@pithy-sh/core/src/naming/provisionScope` for why those
|
|
33
|
+
* two were never safe as separate arguments.
|
|
34
|
+
*
|
|
35
|
+
* Idempotent and resumable: every resource is matched by name before it is created, so a re-run reuses
|
|
36
|
+
* what exists, a hand-created resource of the right name is **adopted** rather than duplicated, and a
|
|
37
|
+
* run interrupted by a network hiccup completes on the next attempt.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** A migrate/seed seam so provisioning's orchestration is testable without a live backend. */
|
|
41
|
+
export type BackendRunner = (args: { env: string; projectDir: string }) => Promise<void>;
|
|
42
|
+
|
|
43
|
+
// The migrate names its project for the same reason the seed below does, and one more: the stamp it
|
|
44
|
+
// writes is what refuses a later run from another project. A fresh environment's D1 is brand new, so
|
|
45
|
+
// this run is the one that adopts it — skip the name here and the database stays unowned for good.
|
|
46
|
+
const defaultMigrate: BackendRunner = async ({ env, projectDir }) => {
|
|
47
|
+
// One config load, two facts, both from the project's own root config: the project the brand-new D1 is
|
|
48
|
+
// stamped for, and the account it is created and migrated in. A provisioned environment is remote by
|
|
49
|
+
// definition, so this is the account that decides *whose tenant* the schema lands in (#234).
|
|
50
|
+
const config = await loadProject(projectDir);
|
|
51
|
+
await migrateProject({
|
|
52
|
+
env,
|
|
53
|
+
projectDir,
|
|
54
|
+
project: requireProjectName(config),
|
|
55
|
+
account: loadProjectCloudflare(config) ?? null,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The seed names its project because a fixture can mint Cloudflare Images/Stream assets, and those two
|
|
60
|
+
// account-flat stores carry no name we chose — only the owner in their metadata. `requireProjectName`,
|
|
61
|
+
// the same resolver every provisioned resource name already leads with.
|
|
62
|
+
const defaultSeed: BackendRunner = async ({ env, projectDir }) => {
|
|
63
|
+
const config = await loadProject(projectDir);
|
|
64
|
+
await seedProject({
|
|
65
|
+
env,
|
|
66
|
+
projectDir,
|
|
67
|
+
project: requireProjectName(config),
|
|
68
|
+
account: loadProjectCloudflare(config) ?? null,
|
|
69
|
+
yes: true,
|
|
70
|
+
json: true,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where a run's resource ids are recorded **outside** the wrangler stanza, when the scope wants that.
|
|
76
|
+
*
|
|
77
|
+
* A feature has one: its resources are ephemeral and something has to be able to delete them exactly,
|
|
78
|
+
* including after a run that failed between creating a resource and writing the config. A declared
|
|
79
|
+
* environment has none — its record *is* the `wrangler.jsonc` stanza, which is source, reviewed, and
|
|
80
|
+
* long-lived. Optional rather than defaulted to a file, so "this environment keeps no side record" is a
|
|
81
|
+
* decision a caller made rather than a path nobody noticed.
|
|
82
|
+
*/
|
|
83
|
+
export interface ProvisionRecord {
|
|
84
|
+
/** Everything a previous run recorded that this scope could legitimately have created. */
|
|
85
|
+
load(): Promise<FeatureResource[]>;
|
|
86
|
+
/** Persist the running set. Called after each resource, so an interrupted run resumes from here. */
|
|
87
|
+
save(resources: FeatureResource[]): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One provisioned resource in the report: what it is, and whether this run created it or adopted it. */
|
|
91
|
+
export interface ProvisionedResource extends FeatureResource {
|
|
92
|
+
/** True when this run created the resource; false when it already existed (re-run, or adoption). */
|
|
93
|
+
created: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The structured outcome of a provisioning run — the `--json` payload and the human summary source.
|
|
98
|
+
*
|
|
99
|
+
* **No `migrated`/`seeded` pair, for the reason `CreateReport` states (#231).** Both were literal `true`s
|
|
100
|
+
* beside the two `await`s that ran the steps, and both steps throw, so the report's own existence already
|
|
101
|
+
* carried the fact. A constant is not a field.
|
|
102
|
+
*/
|
|
103
|
+
export interface ProvisionReport {
|
|
104
|
+
/** The environment provisioned — the scope's stanza. */
|
|
105
|
+
env: string;
|
|
106
|
+
/** Every resource, in provision order, flagged created vs. adopted. */
|
|
107
|
+
resources: ProvisionedResource[];
|
|
108
|
+
/** Each Worker and the script name it deploys under in this environment. */
|
|
109
|
+
workers: { worker: string; name: string }[];
|
|
110
|
+
/** Each service binding and the Worker it now targets in this environment. */
|
|
111
|
+
services: ServiceEntry[];
|
|
112
|
+
/** Every `cf-secrets-store` secret this environment declares, and whether it was bound. */
|
|
113
|
+
secretBindings: ProvisionedSecret[];
|
|
114
|
+
/** Where each Worker's ids were written, project-relative — one entry per Worker, in write order. */
|
|
115
|
+
configs: ProvisionedConfig[];
|
|
116
|
+
/**
|
|
117
|
+
* **Are the files above committed, or ignored?** `true` for a declared environment, whose ids are
|
|
118
|
+
* long-lived source a human reviews in a pull request; `false` for a feature, whose ids are one job's
|
|
119
|
+
* output under the already-ignored `.wrangler/`.
|
|
120
|
+
*
|
|
121
|
+
* One flag now decides which of those a run produces, and a flag that flips whether output is committed
|
|
122
|
+
* will eventually surprise someone. So the run says which it did, here and in the human summary — and
|
|
123
|
+
* a pipeline can read the answer rather than infer it, which is what keeps *a CI build never commits
|
|
124
|
+
* back to the repository* a property a script can assert.
|
|
125
|
+
*
|
|
126
|
+
* One boolean for the whole run rather than one per config: a scope is chosen once, so a per-file copy
|
|
127
|
+
* would be N copies of one fact, and every consumer branch on a disagreement they cannot have.
|
|
128
|
+
*/
|
|
129
|
+
committed: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One file a provisioning run wrote a Worker's ids into. */
|
|
133
|
+
export interface ProvisionedConfig {
|
|
134
|
+
/** The Worker's own deploy name — its `wrangler.jsonc` `name`. */
|
|
135
|
+
worker: string;
|
|
136
|
+
/** The file written, relative to the project root. */
|
|
137
|
+
path: string;
|
|
138
|
+
/** How many binding ids landed in it. Zero for a Worker that declares no provisionable binding. */
|
|
139
|
+
ids: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One declared Secrets Store secret, and whether the environment now binds it. */
|
|
143
|
+
export interface ProvisionedSecret {
|
|
144
|
+
/** The Worker binding name, which is the registry key. */
|
|
145
|
+
binding: string;
|
|
146
|
+
/** The store entry it resolves to in this environment. */
|
|
147
|
+
entry: string;
|
|
148
|
+
/**
|
|
149
|
+
* True when the entry exists and the binding was written. False when the secret is declared and its
|
|
150
|
+
* entry has never been created — bound anyway, wrangler would refuse the whole config, so one absent
|
|
151
|
+
* value would fail the Worker's deploy rather than one read.
|
|
152
|
+
*/
|
|
153
|
+
bound: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* True when **this run** created the value, because the registry declared it may be minted (#321).
|
|
156
|
+
*
|
|
157
|
+
* Reported for the same reason a created resource is distinguished from an adopted one: a run that
|
|
158
|
+
* generated a key-encryption key did something an operator needs to be able to see in the log, and a
|
|
159
|
+
* re-run that found one already there did not. The value itself is nowhere — here or anywhere.
|
|
160
|
+
*/
|
|
161
|
+
minted: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One Worker as provisioning needs it: where it lives, and what *it* composes. */
|
|
165
|
+
export interface ProvisionWorker {
|
|
166
|
+
/** The Worker's deploy name — its `wrangler.jsonc` `name` — which the scoped script name derives from. */
|
|
167
|
+
name: string;
|
|
168
|
+
/** The Worker's directory — the `wrangler.jsonc` this run writes into, and the `apps/<name>` a sibling's service binding names it by. */
|
|
169
|
+
dir: string;
|
|
170
|
+
/** That Worker's own capabilities, from its `apps/<name>/pithy.config.ts`. */
|
|
171
|
+
capabilities: Capability[];
|
|
172
|
+
/**
|
|
173
|
+
* That Worker's own `pithy.config.ts`. Only `declinedBindings` is read from it — a binding this Worker
|
|
174
|
+
* declines gets no resource created for it, because the decline said the resource is not wanted.
|
|
175
|
+
* Optional: the resolver is a seam, and a caller with no config to give is a Worker declining nothing.
|
|
176
|
+
*/
|
|
177
|
+
config?: WorkerConfig;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Options for {@link provisionEnvironment}. */
|
|
181
|
+
export interface ProvisionEnvironmentOptions {
|
|
182
|
+
/** The project root — where `apps/` lives. */
|
|
183
|
+
projectDir: string;
|
|
184
|
+
/** The scope: what the resources are named, and which `env.<name>` stanza their ids are written into. */
|
|
185
|
+
scope: ProvisionScope;
|
|
186
|
+
/**
|
|
187
|
+
* Every capability the environment spans — the union of each Worker's own `apps/<name>/pithy.config.ts`,
|
|
188
|
+
* deduped by name. It is a union rather than a per-Worker loop because an environment is one
|
|
189
|
+
* environment: `provisionableBindings` dedupes by **binding name**, and sharing is keyed on exactly that
|
|
190
|
+
* — two Workers that both declare `DB` get one database, and a Worker wanting its own declares a
|
|
191
|
+
* different binding.
|
|
192
|
+
*/
|
|
193
|
+
capabilities: Capability[];
|
|
194
|
+
/** The provisioners to create through (`cloudflareProvisioners` over live CF clients in a real run). */
|
|
195
|
+
provisioners: ResourceProvisioners;
|
|
196
|
+
/** A side record of what was created, for a scope whose resources are torn down automatically. */
|
|
197
|
+
record?: ProvisionRecord;
|
|
198
|
+
/**
|
|
199
|
+
* Whether to load seed data once the schema is up.
|
|
200
|
+
*
|
|
201
|
+
* **Required, and a word each caller writes down.** A feature environment is created empty and is
|
|
202
|
+
* useless without fixtures, so it always seeds. A declared environment already holds real rows, and
|
|
203
|
+
* "provisioning quietly also seeded staging" is not a default anyone should have to discover. A
|
|
204
|
+
* boolean with a default here would have made those two the same decision made once, by whoever
|
|
205
|
+
* wrote the default.
|
|
206
|
+
*/
|
|
207
|
+
seedData: boolean;
|
|
208
|
+
/** Migration runner seam (default: `migrateProject`). */
|
|
209
|
+
migrate?: BackendRunner;
|
|
210
|
+
/** Seed runner seam (default: `seedProject`). */
|
|
211
|
+
seed?: BackendRunner;
|
|
212
|
+
/**
|
|
213
|
+
* Worker-resolution seam (default: {@link resolveWorkers}), so tests fix the worker set. Each entry
|
|
214
|
+
* carries that Worker's **own** capabilities, which is what lets the write step give a Worker only
|
|
215
|
+
* the bindings it declares.
|
|
216
|
+
*/
|
|
217
|
+
resolveWorkers?: (projectDir: string) => Promise<ProvisionWorker[]>;
|
|
218
|
+
/**
|
|
219
|
+
* This Worker's `secrets_store_secrets` entries, named for the scope — the stanza `pithy add` could
|
|
220
|
+
* not write. Omitted when no account or store id is in hand, in which case no stanza is written and
|
|
221
|
+
* nothing already there is disturbed.
|
|
222
|
+
*/
|
|
223
|
+
secretBindings?: (
|
|
224
|
+
capabilities: Capability[],
|
|
225
|
+
) => Promise<{ bound: SecretStoreBinding[]; missing: string[]; minted: string[] }>;
|
|
226
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
227
|
+
audit?: CliAuditEmit;
|
|
228
|
+
/**
|
|
229
|
+
* Extra metadata every creation event carries — what makes a feature's trail say *which* feature.
|
|
230
|
+
* The scope knows the names; only the caller knows why this environment exists.
|
|
231
|
+
*/
|
|
232
|
+
auditMetadata?: Record<string, unknown>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The real worker resolver: every Worker under `apps/`, each with its own capabilities loaded from its
|
|
237
|
+
* `apps/<name>/pithy.config.ts`.
|
|
238
|
+
*/
|
|
239
|
+
const defaultResolveWorkers = async (projectDir: string): Promise<ProvisionWorker[]> =>
|
|
240
|
+
(await resolveWorkers({ projectDir })).map((worker) => ({
|
|
241
|
+
name: worker.name,
|
|
242
|
+
dir: worker.dir,
|
|
243
|
+
capabilities: worker.capabilities,
|
|
244
|
+
config: worker.config,
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Resolve a `service` binding's target to the script name that Worker actually deploys under.
|
|
249
|
+
*
|
|
250
|
+
* A service binding names its target as it appears in `apps/<name>/` (`BindingSpec.service`), but a Worker
|
|
251
|
+
* deploys under its `wrangler.jsonc` `name` — and the two diverge routinely (`pithy init replay` writes
|
|
252
|
+
* `apps/board/wrangler.jsonc` with `"name": "replay-board"`). Scoping the directory name would point the
|
|
253
|
+
* binding at a script nobody deploys: RPC through that binding fails and provisioning reports success. So
|
|
254
|
+
* both sides go through the resolved Worker set, which carries the deploy name, and the directory basename
|
|
255
|
+
* is only the key.
|
|
256
|
+
*
|
|
257
|
+
* A target that matches no Worker is refused rather than guessed: provisioning writes an `env.<name>`
|
|
258
|
+
* stanza only for the Workers it resolved, so nothing else can be scoped correctly, and a silently
|
|
259
|
+
* dangling service name is the exact failure this resolution exists to remove.
|
|
260
|
+
*/
|
|
261
|
+
function resolveServiceTarget(workers: readonly ProvisionWorker[], target: string): string {
|
|
262
|
+
const found = workers.find((worker) => worker.name === target || worker.dir.endsWith(`/${target}`));
|
|
263
|
+
if (!found) {
|
|
264
|
+
throw new ValidationError({
|
|
265
|
+
message: `A service binding targets "${target}", which is not one of this project's workers.`,
|
|
266
|
+
action: `Name the target as its apps/<name> directory. Known: ${workers.map((worker) => worker.name).join(", ") || "none"}.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return found.name;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Provision (or resume provisioning) one environment's Cloudflare resources. For each provisionable
|
|
274
|
+
* binding: compute its name from the scope, adopt the resource if one of that name already exists, else
|
|
275
|
+
* create it, and record it. Then write the ids, the scoped script name, and the retargeted service
|
|
276
|
+
* bindings into **each Worker's own** `wrangler.jsonc`, and run remote migrate + seed (both idempotent).
|
|
277
|
+
* Returns a report; safe to re-run.
|
|
278
|
+
*/
|
|
279
|
+
export async function provisionEnvironment(options: ProvisionEnvironmentOptions): Promise<ProvisionReport> {
|
|
280
|
+
const audit = options.audit ?? (async () => {});
|
|
281
|
+
const { scope } = options;
|
|
282
|
+
// Resolve the Workers first. Their deploy names are what every service binding is retargeted at, so an
|
|
283
|
+
// unresolvable target must fail here — before a single Cloudflare resource is created.
|
|
284
|
+
const workers = await (options.resolveWorkers ?? defaultResolveWorkers)(options.projectDir);
|
|
285
|
+
|
|
286
|
+
// **Declines are per Worker, and a resource survives one Worker declining it.** The environment
|
|
287
|
+
// provisions one resource per binding *name* — that is how two Workers share a database — so a binding
|
|
288
|
+
// is skipped only when every Worker that declares it declines it. Resolved through the reconcile
|
|
289
|
+
// engine's own `honoredDeclineNames` so `pithy upgrade` and `pithy provision` cannot come to mean two
|
|
290
|
+
// different things by "declined" (#440).
|
|
291
|
+
const manifests = (await availableManifests(options.projectDir)).manifests;
|
|
292
|
+
const declinedPerWorker = new Map<string, ReadonlySet<string>>();
|
|
293
|
+
for (const worker of workers) {
|
|
294
|
+
declinedPerWorker.set(
|
|
295
|
+
worker.name,
|
|
296
|
+
honoredDeclineNames({ manifests, capabilities: worker.capabilities, workerConfig: worker.config }),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const wantedSomewhere = new Set(
|
|
300
|
+
workers.flatMap((worker) =>
|
|
301
|
+
provisionableBindings(worker.capabilities, declinedPerWorker.get(worker.name)).map((b) => b.binding),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
// The union is still the source of the *kinds* — `options.capabilities` spans the environment, and a
|
|
305
|
+
// Worker resolver seam may hand back fewer Workers than that union was built from. Only names no
|
|
306
|
+
// Worker wants are dropped.
|
|
307
|
+
const bindings = provisionableBindings(options.capabilities).filter(
|
|
308
|
+
(binding) => workers.length === 0 || wantedSomewhere.has(binding.binding),
|
|
309
|
+
);
|
|
310
|
+
const services = serviceBindings(options.capabilities).map((service) => ({
|
|
311
|
+
binding: service.binding,
|
|
312
|
+
service: scope.worker(resolveServiceTarget(workers, service.target)),
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
const recorded: FeatureResource[] = options.record ? await options.record.load() : [];
|
|
316
|
+
const byBinding = new Map(recorded.map((resource) => [`${resource.kind}:${resource.binding}`, resource]));
|
|
317
|
+
|
|
318
|
+
const resources: ProvisionedResource[] = [];
|
|
319
|
+
for (const { binding, kind } of bindings) {
|
|
320
|
+
const name = scope.resource(binding, kind);
|
|
321
|
+
const provisioner = options.provisioners[kind];
|
|
322
|
+
const found = await provisioner.find(name);
|
|
323
|
+
const id = found ? found.id : (await provisioner.create(name)).id;
|
|
324
|
+
const resource: FeatureResource = { kind, binding, name, id };
|
|
325
|
+
byBinding.set(`${kind}:${binding}`, resource);
|
|
326
|
+
await options.record?.save([...byBinding.values()]); // persist after each — a crash mid-run resumes from here.
|
|
327
|
+
resources.push({ ...resource, created: found === null });
|
|
328
|
+
|
|
329
|
+
// Record only a genuine creation; a run that adopted an existing resource changed nothing.
|
|
330
|
+
if (!found) {
|
|
331
|
+
await audit({
|
|
332
|
+
environment: scope.stanza,
|
|
333
|
+
action: ProvisionAuditActions.resourceCreated,
|
|
334
|
+
outcome: "success",
|
|
335
|
+
resourceType: AUDIT_RESOURCE_TYPE[kind],
|
|
336
|
+
resourceId: id,
|
|
337
|
+
metadata: { ...options.auditMetadata, name, binding },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Write the ids, the scoped script name, and the service targets into **each Worker's own**
|
|
343
|
+
// `wrangler.jsonc` — the file wrangler actually reads, and the file `migrate`/`seed` resolve binding ids
|
|
344
|
+
// from. There is no root Worker: every Worker lives in `apps/<name>/` and owns its wrangler config.
|
|
345
|
+
//
|
|
346
|
+
// **A Worker receives only the bindings its own config declares.** The run provisions one resource per
|
|
347
|
+
// binding name across the whole environment (that is how two Workers share a database — same binding
|
|
348
|
+
// name, same resource), but the *wiring* is per Worker: handing a Worker ids for resources it never
|
|
349
|
+
// declared would put bindings in its wrangler config that it has no business holding.
|
|
350
|
+
const secrets: ProvisionedSecret[] = [];
|
|
351
|
+
const configs: ProvisionedConfig[] = [];
|
|
352
|
+
for (const worker of workers) {
|
|
353
|
+
const declared = new Set(
|
|
354
|
+
provisionableBindings(worker.capabilities, declinedPerWorker.get(worker.name)).map((binding) => binding.binding),
|
|
355
|
+
);
|
|
356
|
+
const workerSecrets = (await options.secretBindings?.(worker.capabilities)) ?? {
|
|
357
|
+
bound: [],
|
|
358
|
+
missing: [],
|
|
359
|
+
minted: [],
|
|
360
|
+
};
|
|
361
|
+
const minted = new Set(workerSecrets.minted);
|
|
362
|
+
for (const entry of workerSecrets.bound) {
|
|
363
|
+
secrets.push({
|
|
364
|
+
binding: entry.binding,
|
|
365
|
+
entry: entry.secret_name,
|
|
366
|
+
bound: true,
|
|
367
|
+
minted: minted.has(entry.binding),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
for (const binding of workerSecrets.missing) {
|
|
371
|
+
secrets.push({ binding, entry: scope.secretEntry(binding, "environment"), bound: false, minted: false });
|
|
372
|
+
}
|
|
373
|
+
const written = resources.filter((resource) => declared.has(resource.binding));
|
|
374
|
+
const destination = await applyProvisionedEnv({
|
|
375
|
+
workerDir: worker.dir,
|
|
376
|
+
worker: worker.name,
|
|
377
|
+
scope,
|
|
378
|
+
resources: written,
|
|
379
|
+
secrets: workerSecrets.bound,
|
|
380
|
+
// Likewise: only the service bindings this Worker declares, retargeted at this environment's copy.
|
|
381
|
+
services: serviceBindings(worker.capabilities).map((service) => ({
|
|
382
|
+
binding: service.binding,
|
|
383
|
+
service: scope.worker(resolveServiceTarget(workers, service.target)),
|
|
384
|
+
})),
|
|
385
|
+
});
|
|
386
|
+
// The path the writer wrote, taken from the writer — never recomputed here. A report that names one
|
|
387
|
+
// file while another was edited is the failure the report exists to prevent (#251).
|
|
388
|
+
configs.push({ worker: worker.name, path: relative(options.projectDir, destination), ids: written.length });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const migrate = options.migrate ?? defaultMigrate;
|
|
392
|
+
const seed = options.seed ?? defaultSeed;
|
|
393
|
+
// migrate and seed fan out over the Workers themselves, each against its own wrangler.jsonc — the file
|
|
394
|
+
// this run just wrote the environment's binding ids into.
|
|
395
|
+
await migrate({ env: scope.stanza, projectDir: options.projectDir });
|
|
396
|
+
if (options.seedData) await seed({ env: scope.stanza, projectDir: options.projectDir });
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
env: scope.stanza,
|
|
400
|
+
resources,
|
|
401
|
+
workers: workers.map((worker) => ({ worker: worker.name, name: scope.worker(worker.name) })),
|
|
402
|
+
services,
|
|
403
|
+
secretBindings: secrets,
|
|
404
|
+
configs,
|
|
405
|
+
committed: scope.source,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { FEATURE_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **A feature's config is a build artifact, so it is not written where source lives.**
|
|
9
|
+
*
|
|
10
|
+
* A feature's ids could once land in `apps/<worker>/wrangler.jsonc` — a
|
|
11
|
+
* tracked, committed file, and one that cannot be gitignored because it is the project's real config.
|
|
12
|
+
* In CI that is correct as designed: the checkout is throwaway, wrangler reads the stanza, the job
|
|
13
|
+
* ends, nothing is committed. Everywhere else it was an expectation rather than a guarantee. A
|
|
14
|
+
* developer in a feature worktree carried a modified tracked file they had not edited, with nothing
|
|
15
|
+
* saying it must not be committed; `git add -A` put ids for since-deleted resources onto `main`, and
|
|
16
|
+
* `feature destroy` — which reverses every other thing provisioning did — did not reverse the edit.
|
|
17
|
+
*
|
|
18
|
+
* **The rule, and it is a shape rather than a warning: a CI run never writes a tracked file.** So the
|
|
19
|
+
* feature's config is generated beside the Worker's, under `.wrangler/`, which every scaffolded
|
|
20
|
+
* project has ignored since the first release and ignores at any depth. Nothing new to add to a
|
|
21
|
+
* `.gitignore`, nothing for an existing project to adopt, and no `git add -A` that can reach it. It
|
|
22
|
+
* also answers the question `destroy` could not: a feature abandoned without teardown leaves an ignored
|
|
23
|
+
* file in a directory nothing reads, rather than a stranded edit to source.
|
|
24
|
+
*
|
|
25
|
+
* `main` is rewritten to an absolute path because wrangler resolves a config's paths relative to the
|
|
26
|
+
* config file, and this one sits two directories deeper than the file it was generated from.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Where the generated config for a feature environment lives, under the already-ignored `.wrangler/`. */
|
|
30
|
+
export function featureConfigPath(workerDir: string): string {
|
|
31
|
+
return join(workerDir, ".wrangler", "pithy", "wrangler.feature.jsonc");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The file a provisioning run writes one Worker's ids into: the tracked `wrangler.jsonc` when they are
|
|
36
|
+
* source, the generated config when they are a build artifact.
|
|
37
|
+
*
|
|
38
|
+
* **One function, because the writer and the report have to agree.** `pithy provision` states the file it
|
|
39
|
+
* wrote and whether it is committed, and a report naming one path while the writer edits another is worse
|
|
40
|
+
* than no report at all — it is a note in a runbook contradicting the code. Both go through here, so
|
|
41
|
+
* "which file?" is answered once, by `ProvisionScope.source`, which is also what answered "what is it
|
|
42
|
+
* called?" and "which stanza?".
|
|
43
|
+
*/
|
|
44
|
+
export function provisionConfigPath(workerDir: string, source: boolean): string {
|
|
45
|
+
return source ? join(workerDir, "wrangler.jsonc") : featureConfigPath(workerDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The config file a command should read (or hand to wrangler) for one Worker and one environment.
|
|
50
|
+
*
|
|
51
|
+
* A feature environment resolves to the generated file; everything else to the Worker's own tracked
|
|
52
|
+
* `wrangler.jsonc`. One resolver, so `migrate`, `seed` and `deploy` cannot disagree about which bytes
|
|
53
|
+
* describe the environment they are acting on.
|
|
54
|
+
*/
|
|
55
|
+
export function wranglerConfigPath(workerDir: string, env: string): string {
|
|
56
|
+
return provisionConfigPath(workerDir, isSourceEnvironment(env));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is this environment's config **source** — the tracked `wrangler.jsonc` — rather than a generated one?
|
|
61
|
+
*
|
|
62
|
+
* The same question `ProvisionScope.source` answers for a provisioning run, asked by a caller that has
|
|
63
|
+
* only an environment name. One predicate rather than two comparisons against a literal, so a command
|
|
64
|
+
* that starts handling feature environments cannot answer it differently from the writer.
|
|
65
|
+
*
|
|
66
|
+
* A project cannot declare `feature` as one of its environments, so the two answers can never both be
|
|
67
|
+
* true of one name — `DeclaredEnvironments` refuses it for exactly this reason.
|
|
68
|
+
*/
|
|
69
|
+
export function isSourceEnvironment(env: string): boolean {
|
|
70
|
+
return env !== FEATURE_ENVIRONMENT;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The path-valued fields wrangler resolves relative to the config file, and this file therefore rewrites. */
|
|
74
|
+
const RELATIVE_PATHS = ["main"] as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rewrite a generated config's relative paths against the directory it was generated *from*.
|
|
78
|
+
*
|
|
79
|
+
* Wrangler resolves `main` relative to the configuration file's own location, and the generated file
|
|
80
|
+
* sits under `<worker>/.wrangler/pithy/`. Left alone, `"main": "src/index.ts"` would name a file that
|
|
81
|
+
* does not exist and the deploy would fail on it — the exact class of quiet breakage this whole thread
|
|
82
|
+
* is about, so it is fixed here rather than left to the first adopter to hit it.
|
|
83
|
+
*
|
|
84
|
+
* `assets.directory` gets the same treatment when a Worker carries a UI.
|
|
85
|
+
*/
|
|
86
|
+
export function absolutizePaths(config: Record<string, unknown>, workerDir: string): void {
|
|
87
|
+
for (const key of RELATIVE_PATHS) {
|
|
88
|
+
const value = config[key];
|
|
89
|
+
if (typeof value === "string" && !isAbsolute(value)) config[key] = join(workerDir, value);
|
|
90
|
+
}
|
|
91
|
+
const assets = config.assets;
|
|
92
|
+
if (assets && typeof assets === "object") {
|
|
93
|
+
const directory = (assets as { directory?: unknown }).directory;
|
|
94
|
+
if (typeof directory === "string" && !isAbsolute(directory)) {
|
|
95
|
+
(assets as { directory?: unknown }).directory = join(workerDir, directory);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **Which environment `pithy provision` is provisioning — the flag, and nothing else.**
|
|
8
|
+
*
|
|
9
|
+
* Provisioning is one job with two spellings. A declared environment and a branch's differ only in how
|
|
10
|
+
* the target is *named*: from the root `pithy.config.ts`, or from the checked-out branch. That is a flag,
|
|
11
|
+
* not a different verb, and it was only ever two commands because the naming and the destination stanza
|
|
12
|
+
* used to be independent arguments. `ProvisionScope` fused them, so the safety no longer lives in which
|
|
13
|
+
* command was typed.
|
|
14
|
+
*
|
|
15
|
+
* **`--feature` is declared, never inferred.** Nothing here reads a branch, and nothing may: switching
|
|
16
|
+
* mode because "the branch looks like a feature branch" is how someone on `feature/…` provisions the
|
|
17
|
+
* wrong thing while reading a command line that says nothing about it. The branch is where a feature's
|
|
18
|
+
* *name* comes from, once the operator has said `--feature`.
|
|
19
|
+
*
|
|
20
|
+
* **Refused here, at the flag.** This is a pure function over two booleans' worth of input: no config is
|
|
21
|
+
* loaded, no account is resolved, no Cloudflare client exists yet. `pithy provision --env staging
|
|
22
|
+
* --feature` therefore fails the same way outside a project as inside one, with the sentence about the
|
|
23
|
+
* flags rather than a sentence about whatever the next step happened to need.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The target of a provisioning run, as the flags named it. */
|
|
27
|
+
export type ProvisionMode =
|
|
28
|
+
| {
|
|
29
|
+
/** A declared environment — `staging`, `prod`, whatever the root config lists. */
|
|
30
|
+
readonly kind: "environment";
|
|
31
|
+
/** The `--env` value, still to be checked against the project's declaration. */
|
|
32
|
+
readonly env: string;
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
/** This branch's own ephemeral environment. */
|
|
36
|
+
readonly kind: "feature";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The two flags this reads. Exactly one of them is required, and passing both is a refusal. */
|
|
40
|
+
export interface ProvisionModeFlags {
|
|
41
|
+
/** `--env <name>`. Absent as `undefined`, and an empty string counts as absent. */
|
|
42
|
+
env?: string | undefined;
|
|
43
|
+
/** `--feature`. */
|
|
44
|
+
feature: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolve the mode, or refuse: exactly one of `--env` and `--feature`, always. */
|
|
48
|
+
export function requireProvisionMode(flags: ProvisionModeFlags): ProvisionMode {
|
|
49
|
+
const named = flags.env !== undefined && flags.env !== "";
|
|
50
|
+
if (named && flags.feature) {
|
|
51
|
+
throw new ValidationError({
|
|
52
|
+
message: "Pass either --env or --feature, not both.",
|
|
53
|
+
action: "--env <name> provisions an environment the project declares. --feature provisions this branch's.",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (named) return { kind: "environment", env: flags.env as string };
|
|
57
|
+
if (flags.feature) return { kind: "feature" };
|
|
58
|
+
throw new ValidationError({
|
|
59
|
+
message: "Provisioning needs an environment to provision.",
|
|
60
|
+
action: "Pass --env <name> for one the project declares, or --feature for this branch's.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
5
|
+
import { managerMintedSecrets } from "../capabilities/mintSecrets";
|
|
6
|
+
import type { ProvisionMode } from "./mode";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **What `pithy provision` declares and cannot create, and who — if anyone — can.**
|
|
10
|
+
*
|
|
11
|
+
* A `d1` secret's value is sealed under a master key that lives inside an environment's secrets manager
|
|
12
|
+
* Worker, so only that manager can write one. `pithy provision` runs *before* the managers are
|
|
13
|
+
* necessarily deployed, so it creates none of them. That limit is real and is not a bug to be papered
|
|
14
|
+
* over; finishing quietly was the bug, and #321 closed it by naming the secrets.
|
|
15
|
+
*
|
|
16
|
+
* **What it then named as the fix was true for one mode only (#330).** The line said *run
|
|
17
|
+
* `pithy secrets provision`* whichever mode had been typed. That command iterates the environments the
|
|
18
|
+
* project **declares** and deploys a manager into each. A branch is not declared and gets no manager —
|
|
19
|
+
* deliberately, by #241: a manager is a Worker with its own D1 and its own rotation cron, and one per
|
|
20
|
+
* open pull request is not a thing anybody wants. So for `--feature` the command does nothing at all,
|
|
21
|
+
* and the operator who ran it learned nothing, which is the exact dead end this area exists to remove.
|
|
22
|
+
*
|
|
23
|
+
* **There is no remedy for a feature environment, and that is the sentence rather than a better
|
|
24
|
+
* command.** Every route to one was checked before this was written:
|
|
25
|
+
*
|
|
26
|
+
* - `pithy secrets provision` spans `projectEnvironments` — the declared set. A branch is never in it.
|
|
27
|
+
* - Giving it a `--feature` mode would deploy a manager per branch, which is the design #241 refused.
|
|
28
|
+
* - The CLI cannot write the row itself. The master key is put into the account's Secrets Store and read
|
|
29
|
+
* back by nothing: the store is write-only to this side, which is the premise the whole design rests on.
|
|
30
|
+
* - `seedDevSecrets` writes rows directly, but into a *local* Miniflare D1 from a local file. There is no
|
|
31
|
+
* remote equivalent, and inventing one here would be a second writer for the sealed store.
|
|
32
|
+
*
|
|
33
|
+
* So a shortfall is stated. A branch's environment comes up without these secrets, and an operator is
|
|
34
|
+
* told that in the run that made it rather than by the first request that needs one.
|
|
35
|
+
*
|
|
36
|
+
* **It warns; it does not refuse.** `pithy provision --feature` runs per pull request, in CI, and
|
|
37
|
+
* refusing every one of them for a gap the command cannot close would break the pipeline without moving
|
|
38
|
+
* the problem. A feature environment is still the thing it was for every capability that needs no `d1`
|
|
39
|
+
* secret.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The `d1` secrets a provisioning run defers, and what creates them — if a command does. */
|
|
43
|
+
export interface PendingSecrets {
|
|
44
|
+
/**
|
|
45
|
+
* The secrets, by name, in registry order. **A fact about the registry, not about the mode**: both
|
|
46
|
+
* modes defer the same set, because both run before any manager exists.
|
|
47
|
+
*/
|
|
48
|
+
names: string[];
|
|
49
|
+
/**
|
|
50
|
+
* The command that creates them, or `null` when no command does.
|
|
51
|
+
*
|
|
52
|
+
* A string rather than a boolean so the run prints the command it has instead of composing one, and so
|
|
53
|
+
* a pipeline reading `--json` branches on the same value the sentence is built from.
|
|
54
|
+
*/
|
|
55
|
+
remedy: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What creates a deferred secret, per mode.
|
|
60
|
+
*
|
|
61
|
+
* **A total record over `ProvisionMode["kind"]`, so a third mode fails the build here** rather than
|
|
62
|
+
* inheriting whichever branch happened to come first — which is precisely how `--feature` came to be
|
|
63
|
+
* told `--env`'s answer. `null` is a mode where nothing does, written down rather than left out.
|
|
64
|
+
*/
|
|
65
|
+
const PENDING_SECRET_REMEDY: Record<ProvisionMode["kind"], string | null> = {
|
|
66
|
+
environment: "pithy secrets provision",
|
|
67
|
+
feature: null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The deferred secrets for one run, from the registry it provisions for and the mode it was asked in.
|
|
72
|
+
*
|
|
73
|
+
* The predicate is {@link managerMintedSecrets} — the same one the creator uses — so a capability that
|
|
74
|
+
* adds an arbitrary `d1` secret tomorrow is named here without a list being maintained.
|
|
75
|
+
*/
|
|
76
|
+
export function pendingSecrets(registry: SecretRegistry, mode: ProvisionMode): PendingSecrets {
|
|
77
|
+
return { names: managerMintedSecrets(registry), remedy: PENDING_SECRET_REMEDY[mode.kind] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The human lines: what was deferred, then what to do about it. Empty when nothing was deferred, so a
|
|
82
|
+
* project declaring no arbitrary `d1` secret reads no paragraph about one.
|
|
83
|
+
*
|
|
84
|
+
* The second line is chosen by `remedy` and by nothing else, so the prose and the `--json` field cannot
|
|
85
|
+
* disagree about whether a command exists. Today the one mode with no remedy is `--feature`, which is
|
|
86
|
+
* what the shortfall sentence describes; the record above is what keeps that true.
|
|
87
|
+
*/
|
|
88
|
+
export function pendingSecretLines(pending: PendingSecrets): string[] {
|
|
89
|
+
if (pending.names.length === 0) return [];
|
|
90
|
+
return [
|
|
91
|
+
`${pending.names.join(", ")}: not created here — they need a deployed manager.`,
|
|
92
|
+
pending.remedy === null
|
|
93
|
+
? "A branch gets no manager, and no command creates these for one. This environment comes up without them."
|
|
94
|
+
: `Run ${pending.remedy} to create them.`,
|
|
95
|
+
];
|
|
96
|
+
}
|