@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,126 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
|
|
6
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The control-plane operations provisioning needs for one resource kind, and the Cloudflare-backed
|
|
10
|
+
* implementation of them.
|
|
11
|
+
*
|
|
12
|
+
* Separate from the orchestration above it because the orchestration is the same for every
|
|
13
|
+
* environment a project has — a branch's ephemeral one and a declared `staging` alike — while this is
|
|
14
|
+
* the one part that talks to an account. Injectable so every test above runs without credentials.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The control-plane operations provisioning needs for one resource kind. */
|
|
18
|
+
export interface ResourceProvisioner {
|
|
19
|
+
/** Find a resource by its exact name, returning its id, or null when absent (id = uuid/nsId, or the bucket name for r2). */
|
|
20
|
+
find(name: string): Promise<{ id: string } | null>;
|
|
21
|
+
/** Create the resource by name and return its id. */
|
|
22
|
+
create(name: string): Promise<{ id: string }>;
|
|
23
|
+
/** Delete the resource by id. Idempotent — a missing resource is not an error. */
|
|
24
|
+
delete(id: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provisioner per provisionable resource kind. */
|
|
28
|
+
export type ResourceProvisioners = Record<FeatureResourceKind, ResourceProvisioner>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The default provisioners, backed by the `@pithy-sh/cloudflare` control-plane clients. For D1 and KV the
|
|
32
|
+
* id is the CF-assigned uuid/namespace id; for R2, which has no separate id, the bucket name is the id.
|
|
33
|
+
*
|
|
34
|
+
* **`find` is where a not-found becomes a creation, so it is where the account has to be settled (#378).**
|
|
35
|
+
* Both callers are find-or-create: `provisionEnvironment` creates whatever `find` did not return, and
|
|
36
|
+
* `destroyFeature` reconciles by expected name. An empty listing from an account nothing claims is not
|
|
37
|
+
* an absence, and reading it as one stands a real D1, KV namespace or R2 bucket up in somebody else's
|
|
38
|
+
* account — a creation no re-run can walk back, since the second run finds what the first made. So the
|
|
39
|
+
* account travels with the clients, `find` refuses instead of returning `null`, and the lookup is not
|
|
40
|
+
* even attempted: the round trip's answer could not be believed either way.
|
|
41
|
+
*
|
|
42
|
+
* **Known limitation, R2 only:** the delete here is the plain control-plane one, and R2 refuses to delete
|
|
43
|
+
* a bucket that still holds an object or a dangling multipart upload — so a feature bucket that was
|
|
44
|
+
* written to fails teardown. Emptying it first needs the S3 key pair, which the control-plane clients do
|
|
45
|
+
* not carry (`pithy storage deprovision --storage` takes it as a flag for exactly this reason). Wiring
|
|
46
|
+
* that through the feature lifecycle is the fix; until then, empty the bucket by hand.
|
|
47
|
+
*/
|
|
48
|
+
export function cloudflareProvisioners(clients: CloudflareClients, account: ConfirmedAccount): ResourceProvisioners {
|
|
49
|
+
const d1 = clients.d1Provisioner();
|
|
50
|
+
const kv = clients.kvProvisioner();
|
|
51
|
+
const r2 = clients.r2Provisioner();
|
|
52
|
+
return {
|
|
53
|
+
d1: {
|
|
54
|
+
find: async (name) => {
|
|
55
|
+
const found = await findOnConfirmedAccount({
|
|
56
|
+
...account,
|
|
57
|
+
what: `the ${name} database`,
|
|
58
|
+
find: () => d1.findDatabaseByName(name),
|
|
59
|
+
});
|
|
60
|
+
return found ? { id: found.uuid } : null;
|
|
61
|
+
},
|
|
62
|
+
create: async (name) => ({ id: (await d1.createDatabase(name)).uuid }),
|
|
63
|
+
delete: (id) => d1.deleteDatabase(id),
|
|
64
|
+
},
|
|
65
|
+
kv: {
|
|
66
|
+
find: async (name) => {
|
|
67
|
+
const found = await findOnConfirmedAccount({
|
|
68
|
+
...account,
|
|
69
|
+
what: `the ${name} KV namespace`,
|
|
70
|
+
find: () => kv.findNamespaceByTitle(name),
|
|
71
|
+
});
|
|
72
|
+
return found ? { id: found.id } : null;
|
|
73
|
+
},
|
|
74
|
+
create: async (name) => ({ id: (await kv.createNamespace(name)).id }),
|
|
75
|
+
delete: (id) => kv.deleteNamespace(id),
|
|
76
|
+
},
|
|
77
|
+
r2: {
|
|
78
|
+
find: async (name) => {
|
|
79
|
+
const found = await findOnConfirmedAccount({
|
|
80
|
+
...account,
|
|
81
|
+
what: `the ${name} bucket`,
|
|
82
|
+
find: () => r2.findBucketByName(name),
|
|
83
|
+
});
|
|
84
|
+
return found ? { id: found.name } : null;
|
|
85
|
+
},
|
|
86
|
+
create: async (name) => ({ id: (await r2.createBucket(name)).name }),
|
|
87
|
+
delete: (id) => r2.deleteBucket(id),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Audit actions provisioning records. Creating and — especially — deleting a resource changes real
|
|
94
|
+
* infrastructure, and both run headlessly in CI, so "who deleted this, and what exactly went?" must be
|
|
95
|
+
* answerable after the fact.
|
|
96
|
+
*
|
|
97
|
+
* **Named for provisioning, not for features**, since the same two events are emitted for a declared
|
|
98
|
+
* environment. The event's own `environment` field says which one it was; a `feature/` prefix on a
|
|
99
|
+
* `staging` event would be a sentence contradicting the row it sits in.
|
|
100
|
+
*/
|
|
101
|
+
export const ProvisionAuditActions = {
|
|
102
|
+
/** A Cloudflare resource was created. */
|
|
103
|
+
resourceCreated: "provision/resource_created",
|
|
104
|
+
/** A Cloudflare resource was deleted during teardown. */
|
|
105
|
+
resourceDeleted: "provision/resource_deleted",
|
|
106
|
+
} as const;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where a provisioning run's audit trail is written. **Not the environment being provisioned.**
|
|
110
|
+
*
|
|
111
|
+
* That environment's database may not exist yet — standing it up is what the command is for — so keying
|
|
112
|
+
* the trail on it would resolve nothing and silently drop every creation event. And a feature's is
|
|
113
|
+
* deleted by `destroy`, so a record written there dies with the thing it was recording. Both defeat the
|
|
114
|
+
* point of auditing a headless CI run.
|
|
115
|
+
*
|
|
116
|
+
* It is a **routing** choice and never an `actedOn` claim: each event states the environment it acted on
|
|
117
|
+
* in its own `environment` field, which is where the real answer is known.
|
|
118
|
+
*/
|
|
119
|
+
export const AUDIT_DESTINATION_ENV = "dev";
|
|
120
|
+
|
|
121
|
+
/** The resource kind recorded on a provisioning audit event. */
|
|
122
|
+
export const AUDIT_RESOURCE_TYPE: Record<FeatureResourceKind, string> = {
|
|
123
|
+
d1: "cf_d1",
|
|
124
|
+
kv: "cf_kv",
|
|
125
|
+
r2: "cf_r2",
|
|
126
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type { ProvisionScope, SecretNameScope } from "@pithy-sh/core/src/naming/provisionScope";
|
|
6
|
+
import { isSecretsCapability } from "@pithy-sh/secrets/src/capability";
|
|
7
|
+
import { isMintableSecret, type SecretRegistry, type SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
|
|
8
|
+
import { aggregateSecretRegistries } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **The `secrets_store_secrets` stanza nothing wrote.**
|
|
12
|
+
*
|
|
13
|
+
* `pithy add` deliberately does not write a `secret` binding, and the reason in
|
|
14
|
+
* `core/src/capability/bindings.ts` is sound: the entry needs a `store_id` and a `secret_name` that do
|
|
15
|
+
* not exist until an account has been reached, so "telling anyone to add one of these to
|
|
16
|
+
* `wrangler.jsonc` sends them somewhere the value does not exist". The defect was that having deferred
|
|
17
|
+
* it, nothing came back — the skip was a decision about *when* and was implemented as a decision about
|
|
18
|
+
* *whether*. A Worker deployed without `SECRETS_ENCRYPTION_KEYS` and failed at its first request.
|
|
19
|
+
*
|
|
20
|
+
* Provisioning is the step being deferred to: it is when the store id is in hand and the entry
|
|
21
|
+
* certainly exists. The entry **name** comes from the scope, so a feature binds its own master key and
|
|
22
|
+
* `staging` binds staging's — the join key is the binding, and the binding never changes.
|
|
23
|
+
*
|
|
24
|
+
* **`dev` never gets a stanza, and that is deliberate rather than an omission.** Local dev materialises
|
|
25
|
+
* every `cf-secrets-store` secret into the generated `.dev.vars` (#179), so a stanza there would name
|
|
26
|
+
* store entries a local run never reads. Provisioning only ever writes `scope.stanza`, which is a
|
|
27
|
+
* deployed or feature environment and never `dev`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One `secrets_store_secrets` entry, complete — wrangler rejects an entry missing any of the three. */
|
|
31
|
+
export interface SecretStoreBinding {
|
|
32
|
+
/** The Worker binding name, which is the registry key: the same name every read site uses. */
|
|
33
|
+
binding: string;
|
|
34
|
+
/** The account's one Secrets Store. */
|
|
35
|
+
store_id: string;
|
|
36
|
+
/** The entry inside it, named for this scope. */
|
|
37
|
+
secret_name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The secret registry a Worker composes, or `null` when it composes no secrets capability at all.
|
|
42
|
+
*
|
|
43
|
+
* **The aggregate, not the secrets capability's own slice.** `aggregateSecretRegistries` is the exact
|
|
44
|
+
* call the Worker makes at composition: every capability contributes the secrets it owns, and
|
|
45
|
+
* `CONNECTION_KEY_ENCRYPTION_KEY` is the adopter's `app` declaration rather than something re-typed into
|
|
46
|
+
* `secrets({ registry })`. Reading the slice bound the master key and nothing else — so a
|
|
47
|
+
* `cf-secrets-store` secret declared by auth, or by the adopter's own capability, got a binding from no
|
|
48
|
+
* command at all, and the Worker booted healthy and failed at the first read of it. That is the half of
|
|
49
|
+
* #238 the master key hid, and it is the same resolution `pithy seed` already uses (`devSecrets/targets`).
|
|
50
|
+
*
|
|
51
|
+
* The `secrets` capability stays the gate. A Worker that declares a `cf-secrets-store` secret and
|
|
52
|
+
* composes no `secrets` has no store to read it from and no `SECRETS` binding to reach one, so there is
|
|
53
|
+
* nothing there to bind.
|
|
54
|
+
*/
|
|
55
|
+
export function workerSecretRegistry(capabilities: readonly Capability[]): SecretRegistry | null {
|
|
56
|
+
if (!capabilities.some(isSecretsCapability)) return null;
|
|
57
|
+
return aggregateSecretRegistries(capabilities);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The registry entries that need a `secrets_store_secrets` binding — one predicate, so a stanza writer
|
|
62
|
+
* and a stanza reader cannot disagree about what belongs in one.
|
|
63
|
+
*
|
|
64
|
+
* **A keyspace is skipped.** It has no single value and therefore no single entry; its members are
|
|
65
|
+
* written one key at a time by the application that mints them, and a binding under the bare name would
|
|
66
|
+
* address an entry nothing ever writes.
|
|
67
|
+
*/
|
|
68
|
+
export function boundSecretNames(registry: SecretRegistry): string[] {
|
|
69
|
+
return Object.entries(registry)
|
|
70
|
+
.filter(([, entry]) => entry.backend === "cf-secrets-store" && !entry.keyed)
|
|
71
|
+
.map(([binding]) => binding);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One secret a run is about to create: what the Worker binds it as, what it is called here, what it is. */
|
|
75
|
+
export interface MintTarget {
|
|
76
|
+
/** The Worker binding name, which is the registry key. */
|
|
77
|
+
binding: string;
|
|
78
|
+
/** The store entry to write, named for this scope. */
|
|
79
|
+
secretName: string;
|
|
80
|
+
/** The registry entry — carries how the value is minted. */
|
|
81
|
+
entry: SecretRegistryEntry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create one declared-but-absent secret. Injected, so the orchestration here is tested without an
|
|
86
|
+
* account, and so a caller with no store credentials simply passes nothing and mints nothing.
|
|
87
|
+
*
|
|
88
|
+
* It resolves once the value is written and readable. It never returns the value, and nothing here ever
|
|
89
|
+
* sees one.
|
|
90
|
+
*/
|
|
91
|
+
export type MintStoreSecret = (target: MintTarget) => Promise<void>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every `cf-secrets-store` secret a registry declares, as a complete binding named for `scope`.
|
|
95
|
+
*
|
|
96
|
+
* Which secrets those are is {@link boundSecretNames}' answer, shared with the reader that reports a
|
|
97
|
+
* stanza missing one — the writer and the check cannot disagree about what belongs in a stanza.
|
|
98
|
+
*
|
|
99
|
+
* `exists` decides what is bound rather than what is declared. Cloudflare rejects a deploy whose
|
|
100
|
+
* `secrets_store_secrets` entry names a secret that is not there, so binding a declared-but-unwritten
|
|
101
|
+
* secret would turn a missing value into a failed deploy of the whole Worker. The caller reports what
|
|
102
|
+
* it left out.
|
|
103
|
+
*
|
|
104
|
+
* **`mint` is what stops provisioning asking a human to generate random bytes (#321).** A secret whose
|
|
105
|
+
* registry entry says its value is arbitrary has no decision in it, and this is the moment the tool
|
|
106
|
+
* already knows the value is absent. So it is created here and bound in the same pass. Three properties
|
|
107
|
+
* hold it in place, and each has a test:
|
|
108
|
+
*
|
|
109
|
+
* - **Absent first.** `exists` is consulted before `mint`, always. Replacing a live key-encryption key
|
|
110
|
+
* orphans every value sealed under it, so creating a missing secret and replacing an existing one are
|
|
111
|
+
* different acts and only the first happens here. A re-run mints nothing.
|
|
112
|
+
* - **Declared first.** {@link isMintableSecret} gates it. A supplied secret stays in `missing`, named
|
|
113
|
+
* on its own line, because a random string there authenticates against nothing.
|
|
114
|
+
* - **This backend only.** The Secrets Store answers *does this exist* authoritatively, which is what
|
|
115
|
+
* makes "never regenerate" checkable. A `d1` secret's value is sealed under the master key inside the
|
|
116
|
+
* manager Worker, and the CLI has no such check for one — so nothing here mints one.
|
|
117
|
+
*/
|
|
118
|
+
export async function secretsStoreBindings(options: {
|
|
119
|
+
/** The registry to read — one Worker's own, so a Worker gets only what it declares. */
|
|
120
|
+
registry: SecretRegistry;
|
|
121
|
+
/** The scope: what each entry is called in this environment. */
|
|
122
|
+
scope: ProvisionScope;
|
|
123
|
+
/** The account's one Secrets Store id. */
|
|
124
|
+
storeId: string;
|
|
125
|
+
/** Whether an entry of that name is in the store. */
|
|
126
|
+
exists: (name: string) => Promise<boolean>;
|
|
127
|
+
/** Creates a declared-but-absent mintable secret. Omitted means create nothing, and report as before. */
|
|
128
|
+
mint?: MintStoreSecret;
|
|
129
|
+
}): Promise<{ bound: SecretStoreBinding[]; missing: string[]; minted: string[] }> {
|
|
130
|
+
const bound: SecretStoreBinding[] = [];
|
|
131
|
+
const missing: string[] = [];
|
|
132
|
+
const minted: string[] = [];
|
|
133
|
+
for (const binding of boundSecretNames(options.registry)) {
|
|
134
|
+
const entry = options.registry[binding] as SecretRegistry[string];
|
|
135
|
+
const secretName = options.scope.secretEntry(binding, entry.scope as SecretNameScope);
|
|
136
|
+
let present = await options.exists(secretName);
|
|
137
|
+
if (!present && options.mint && isMintableSecret(entry)) {
|
|
138
|
+
await options.mint({ binding, secretName, entry });
|
|
139
|
+
minted.push(binding);
|
|
140
|
+
present = true;
|
|
141
|
+
}
|
|
142
|
+
if (present) {
|
|
143
|
+
bound.push({ binding, store_id: options.storeId, secret_name: secretName });
|
|
144
|
+
} else {
|
|
145
|
+
missing.push(binding);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { bound, missing, minted };
|
|
149
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The account's one Secrets Store, as provisioning needs it.
|
|
8
|
+
*
|
|
9
|
+
* A Cloudflare account has exactly one, flat and unpartitionable, so the entry name is the only
|
|
10
|
+
* partition there is — which is why every name here comes from a {@link ProvisionScope} and never from
|
|
11
|
+
* a caller's string. Behind a seam so the orchestration above is tested without credentials.
|
|
12
|
+
*/
|
|
13
|
+
export interface SecretsStore {
|
|
14
|
+
/** The store's id — the `store_id` every `secrets_store_secrets` entry carries. */
|
|
15
|
+
readonly storeId: string;
|
|
16
|
+
/** Is there an entry of this name? */
|
|
17
|
+
exists(name: string): Promise<boolean>;
|
|
18
|
+
/** Write a value under `name`. Overwrites in place; never deletes first. */
|
|
19
|
+
put(name: string, value: string): Promise<void>;
|
|
20
|
+
/** Delete an entry if it is there. Resolves `true` when something was removed. */
|
|
21
|
+
remove(name: string): Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The live store, over the `@pithy-sh/cloudflare` control-plane client. */
|
|
25
|
+
export function cloudflareSecretsStore(clients: CloudflareClients, storeId: string): SecretsStore {
|
|
26
|
+
const store = clients.secrets(storeId);
|
|
27
|
+
return {
|
|
28
|
+
storeId,
|
|
29
|
+
exists: (name) => store.exists(name),
|
|
30
|
+
put: (name, value) => store.putSecret(name, value),
|
|
31
|
+
remove: (name) => store.deleteSecretIfPresent(name),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
|
|
7
|
+
import { discoverWorkers } from "../project/workers";
|
|
8
|
+
import { readOptionalWranglerConfig } from "../project/wrangler";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* **Which of an environment's declared bindings still have no resource behind them.**
|
|
12
|
+
*
|
|
13
|
+
* The state this reads is the one #240 was reported from: a project scaffolded, wired and migrated by
|
|
14
|
+
* pithy, whose `wrangler.jsonc` declares `"database_name": "<project>-staging-db"` and no
|
|
15
|
+
* `database_id`, in every environment. `pithy deploy --env staging` used to hand that straight to
|
|
16
|
+
* wrangler, which failed on a binding the adopter never knew they were meant to create — after `init`,
|
|
17
|
+
* `add`, `migrate` and `dev` had all succeeded.
|
|
18
|
+
*
|
|
19
|
+
* So the answer is read from files, offline, and used twice: `pithy deploy` refuses with the command to
|
|
20
|
+
* run, and `pithy doctor` reports it without being asked. One reader, so the two can never disagree
|
|
21
|
+
* about what "provisioned" means.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One binding an environment declares and has no resource for. */
|
|
25
|
+
export interface UnprovisionedBinding {
|
|
26
|
+
/** The Worker's `apps/<name>` directory. */
|
|
27
|
+
worker: string;
|
|
28
|
+
/** The Cloudflare resource kind the binding needs. */
|
|
29
|
+
kind: FeatureResourceKind;
|
|
30
|
+
/** The binding name in the Worker env, e.g. `DB`. */
|
|
31
|
+
binding: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One binding entry as it appears in a wrangler binding array; only its kind's id field is populated. */
|
|
35
|
+
interface RawBinding {
|
|
36
|
+
binding?: string;
|
|
37
|
+
database_id?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
bucket_name?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The slice of a `wrangler.jsonc` this reads: the environment stanzas and their binding arrays. */
|
|
43
|
+
interface RawWrangler {
|
|
44
|
+
env?: Record<
|
|
45
|
+
string,
|
|
46
|
+
| {
|
|
47
|
+
d1_databases?: RawBinding[];
|
|
48
|
+
kv_namespaces?: RawBinding[];
|
|
49
|
+
r2_buckets?: RawBinding[];
|
|
50
|
+
}
|
|
51
|
+
| undefined
|
|
52
|
+
>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A value is an id only if it is a non-empty, non-placeholder string.
|
|
57
|
+
*
|
|
58
|
+
* The same rule `pithy env` reports `provisioned` by. A scaffold leaves `<database_id>` behind and an
|
|
59
|
+
* adopter leaves `""`; both look filled in to a truthiness check and neither is something wrangler will
|
|
60
|
+
* deploy against.
|
|
61
|
+
*/
|
|
62
|
+
function isId(value: string | undefined): boolean {
|
|
63
|
+
if (value === undefined) return false;
|
|
64
|
+
const trimmed = value.trim();
|
|
65
|
+
return trimmed !== "" && !/^<.+>$/.test(trimmed) && !/placeholder/i.test(trimmed);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every binding one environment declares across the project's Workers that has no id yet, in worker
|
|
70
|
+
* then binding order. A Worker with no stanza for that environment declares nothing there and is
|
|
71
|
+
* skipped; a `wrangler.jsonc` that will not open is skipped too, because this reader gates a deploy and
|
|
72
|
+
* a config that cannot be read has its own, better error waiting one step later.
|
|
73
|
+
*/
|
|
74
|
+
export async function unprovisionedBindings(projectDir: string, env: string): Promise<UnprovisionedBinding[]> {
|
|
75
|
+
const missing: UnprovisionedBinding[] = [];
|
|
76
|
+
for (const target of await discoverWorkers(projectDir)) {
|
|
77
|
+
if (target.hasWrangler === false) continue;
|
|
78
|
+
const raw = (await readOptionalWranglerConfig(target.dir).catch(() => null)) as RawWrangler | null;
|
|
79
|
+
const stanza = raw?.env?.[env];
|
|
80
|
+
if (!stanza) continue;
|
|
81
|
+
const worker = basename(target.dir);
|
|
82
|
+
for (const entry of stanza.d1_databases ?? [])
|
|
83
|
+
if (!isId(entry.database_id)) missing.push({ worker, kind: "d1", binding: entry.binding ?? "" });
|
|
84
|
+
for (const entry of stanza.kv_namespaces ?? [])
|
|
85
|
+
if (!isId(entry.id)) missing.push({ worker, kind: "kv", binding: entry.binding ?? "" });
|
|
86
|
+
for (const entry of stanza.r2_buckets ?? [])
|
|
87
|
+
if (!isId(entry.bucket_name)) missing.push({ worker, kind: "r2", binding: entry.binding ?? "" });
|
|
88
|
+
}
|
|
89
|
+
return missing;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How a report names one unprovisioned binding: the Worker, the binding, and the kind behind it. */
|
|
93
|
+
export function describeUnprovisioned(missing: readonly UnprovisionedBinding[]): string {
|
|
94
|
+
return missing.map((entry) => `${entry.worker}.${entry.binding} (${entry.kind})`).join(", ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Refuse to deploy into an environment whose bindings have no resources, naming the command that
|
|
99
|
+
* creates them.
|
|
100
|
+
*
|
|
101
|
+
* **Deploy refuses; it does not provision.** A deploy that silently creates account resources is hard
|
|
102
|
+
* to review, and the resources it would create are the long-lived ones. The refusal is the whole
|
|
103
|
+
* improvement: the failure moves from inside wrangler, on a field nobody wrote, to one sentence naming
|
|
104
|
+
* the missing binding and the command that fills it.
|
|
105
|
+
*/
|
|
106
|
+
export async function assertEnvironmentProvisioned(projectDir: string, env: string): Promise<void> {
|
|
107
|
+
const missing = await unprovisionedBindings(projectDir, env);
|
|
108
|
+
if (missing.length === 0) return;
|
|
109
|
+
throw new ValidationError({
|
|
110
|
+
message: `${env} declares bindings with no Cloudflare resource behind them: ${describeUnprovisioned(missing)}.`,
|
|
111
|
+
action: `Run pithy provision --env ${env} --yes, then deploy.`,
|
|
112
|
+
detail: "wrangler validates a binding's id before it deploys, so this would have failed inside wrangler.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import type { ProvisionScope } from "@pithy-sh/core/src/naming/provisionScope";
|
|
7
|
+
import { parse } from "comment-json";
|
|
8
|
+
import type { FeatureResource } from "../feature/manifest";
|
|
9
|
+
import { writeJsonc } from "../project/jsonc";
|
|
10
|
+
import { absolutizePaths, provisionConfigPath } from "./featureConfig";
|
|
11
|
+
import type { SecretStoreBinding } from "./secretBindings";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* After provisioning stands up an environment's D1/KV/R2 resources, their ids must land in each
|
|
15
|
+
* Worker's `wrangler.jsonc` under `env.<name>` — that is exactly where the shared `migrate`/`seed`
|
|
16
|
+
* remote drivers, and `wrangler deploy --env <name>`, read each binding's id from. This writes them
|
|
17
|
+
* there, upserting by binding name and preserving every comment in the JSONC (a `pithy.config` output,
|
|
18
|
+
* per CLAUDE.md).
|
|
19
|
+
*
|
|
20
|
+
* **One writer, taking the scope.** The stanza key is `scope.stanza` and every name is the same
|
|
21
|
+
* scope's, so the file this writes into and the names it writes cannot come from two different
|
|
22
|
+
* decisions — which is how a feature-named resource could once be written into a declared environment's
|
|
23
|
+
* stanza.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One binding-id entry keyed by binding, plus the id field that resource kind uses in wrangler.jsonc. */
|
|
27
|
+
interface BindingEntry {
|
|
28
|
+
binding: string;
|
|
29
|
+
[field: string]: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One `services` entry: the binding name and the Worker script it resolves to in this environment. */
|
|
33
|
+
export interface ServiceEntry {
|
|
34
|
+
/** The binding name in the Worker env (e.g. `BOARD`). */
|
|
35
|
+
binding: string;
|
|
36
|
+
/** The script name that binding reaches in this scope. */
|
|
37
|
+
service: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The env stanza slice provisioning writes: the Worker's own name, its binding arrays, and its services. */
|
|
41
|
+
interface EnvBindings {
|
|
42
|
+
name?: string;
|
|
43
|
+
d1_databases?: BindingEntry[];
|
|
44
|
+
kv_namespaces?: BindingEntry[];
|
|
45
|
+
r2_buckets?: BindingEntry[];
|
|
46
|
+
services?: ServiceEntry[];
|
|
47
|
+
secrets_store_secrets?: SecretStoreBinding[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Upsert a binding's fields into a binding array **in place**, so comment-json's array-internal
|
|
52
|
+
* comments (stored as symbol-keyed properties on the array object) survive — a `filter()` would return
|
|
53
|
+
* a plain array and silently drop them. A matching binding's fields are updated on the existing entry;
|
|
54
|
+
* otherwise the entry is pushed.
|
|
55
|
+
*/
|
|
56
|
+
function upsertByBinding(entries: BindingEntry[], binding: string, fields: Record<string, string>): void {
|
|
57
|
+
const existing = entries.find((entry) => entry.binding === binding);
|
|
58
|
+
if (existing) Object.assign(existing, fields);
|
|
59
|
+
else entries.push({ binding, ...fields });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `EnvBindings` keys holding a binding array — the only ones an id is upserted into. */
|
|
63
|
+
type BindingArrayKey = "d1_databases" | "kv_namespaces" | "r2_buckets";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The wrangler key and the fields provisioning owns for each resource kind.
|
|
67
|
+
*
|
|
68
|
+
* **D1 carries its name as well as its id**, and that is not decoration: `pithy add` proposes a
|
|
69
|
+
* `database_name` offline, before any account has been reached, and provisioning is the step that makes
|
|
70
|
+
* the proposal true. Writing only the id would leave a stanza asserting one name while addressing a
|
|
71
|
+
* database that may carry another — the two must be written by the same step or they drift.
|
|
72
|
+
*/
|
|
73
|
+
const KIND_TO_WRANGLER: Record<
|
|
74
|
+
FeatureResource["kind"],
|
|
75
|
+
{ array: BindingArrayKey; fields: (r: FeatureResource) => Record<string, string> }
|
|
76
|
+
> = {
|
|
77
|
+
d1: { array: "d1_databases", fields: (r) => ({ database_name: r.name, database_id: r.id }) },
|
|
78
|
+
kv: { array: "kv_namespaces", fields: (r) => ({ id: r.id }) },
|
|
79
|
+
r2: { array: "r2_buckets", fields: (r) => ({ bucket_name: r.id }) },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write one Worker's whole `env.<scope.stanza>` stanza: the resource ids it declares, the script name it
|
|
84
|
+
* deploys under in this scope, and each of its `service` bindings retargeted at this scope's copy of the
|
|
85
|
+
* callee.
|
|
86
|
+
*
|
|
87
|
+
* The stanza is created when absent and reused when present, so this is also what makes provisioning the
|
|
88
|
+
* creator of a stanza for an environment declared after the project was scaffolded. Idempotent, and every
|
|
89
|
+
* comment in the file survives the round trip.
|
|
90
|
+
*/
|
|
91
|
+
async function editStanza(
|
|
92
|
+
workerDir: string,
|
|
93
|
+
stanzaKey: string,
|
|
94
|
+
/**
|
|
95
|
+
* **`source` decides the file, not the caller.** A declared environment's ids are read in a pull
|
|
96
|
+
* request, so they go into the tracked `wrangler.jsonc`. A feature's are one job's output, so they go
|
|
97
|
+
* into the generated config under the already-ignored `.wrangler/` — regenerated from the tracked
|
|
98
|
+
* file every run, so it can never drift from it, and never making it dirty. That is what makes "a CI
|
|
99
|
+
* run never commits back" a property of the code rather than a note in a runbook.
|
|
100
|
+
*/
|
|
101
|
+
source: boolean,
|
|
102
|
+
mutate: (stanza: EnvBindings) => void,
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
const raw = await readFile(join(workerDir, "wrangler.jsonc"), "utf8");
|
|
105
|
+
const config = parse(raw) as unknown as { env?: Record<string, EnvBindings | undefined> };
|
|
106
|
+
|
|
107
|
+
config.env ??= {};
|
|
108
|
+
const stanza: EnvBindings = config.env[stanzaKey] ?? {};
|
|
109
|
+
config.env[stanzaKey] = stanza;
|
|
110
|
+
|
|
111
|
+
mutate(stanza);
|
|
112
|
+
|
|
113
|
+
// One resolver for "which file?", shared with what the command reports (#251). A run states where it
|
|
114
|
+
// wrote and whether that file is committed; a report computing the path a second time is a sentence
|
|
115
|
+
// that can disagree with the write it describes.
|
|
116
|
+
const destination = provisionConfigPath(workerDir, source);
|
|
117
|
+
if (!source) {
|
|
118
|
+
// Generated, so every path in it is rewritten against the directory it came from — wrangler
|
|
119
|
+
// resolves a config's paths relative to the config, and this one lives two levels deeper.
|
|
120
|
+
absolutizePaths(config as Record<string, unknown>, workerDir);
|
|
121
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
122
|
+
await writeJsonc(destination, config);
|
|
123
|
+
return destination;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Through the one JSONC printer (#249), never a raw `stringify`. `comment-json` puts every array
|
|
127
|
+
// element on its own line; the project's own scaffolded Biome collapses a short one — so a config
|
|
128
|
+
// written the other way fails the pre-commit hook this CLI installed. `writeJsonc` also keeps an
|
|
129
|
+
// adopter's hand-expanded objects expanded, which matters most here: this file is edited in place on
|
|
130
|
+
// every provision, and a two-line change buried in a whole-file reformat is a change nobody reviewed.
|
|
131
|
+
await writeJsonc(destination, config);
|
|
132
|
+
return destination;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Upsert the `secrets_store_secrets` entries provisioning owns into one Worker's `env.<stanza>`.
|
|
137
|
+
*
|
|
138
|
+
* Separate from {@link applyProvisionedEnv} because two commands reach it for different reasons.
|
|
139
|
+
* `pithy provision` writes the whole stanza in either mode; `pithy secrets provision`
|
|
140
|
+
* writes only this, for a project whose resources are already in place and whose store entries have
|
|
141
|
+
* just been created — the five cases `ensureSecretsStoreId` cannot resolve at `add` time, and every
|
|
142
|
+
* project that predates the stanza existing at all.
|
|
143
|
+
*
|
|
144
|
+
* **Only the entries it owns.** An adopter's hand-added binding this registry does not declare is left
|
|
145
|
+
* exactly where it is.
|
|
146
|
+
*/
|
|
147
|
+
export async function applySecretBindings(
|
|
148
|
+
workerDir: string,
|
|
149
|
+
stanzaKey: string,
|
|
150
|
+
entries: readonly SecretStoreBinding[],
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (entries.length === 0) return;
|
|
153
|
+
// Always the tracked file: only `pithy secrets provision` calls this directly, and it acts on the
|
|
154
|
+
// environments a project deploys to. A feature's stanza is written by the scope-driven writer below.
|
|
155
|
+
await editStanza(workerDir, stanzaKey, true, (stanza) => {
|
|
156
|
+
stanza.secrets_store_secrets ??= [];
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const existing = stanza.secrets_store_secrets.find((candidate) => candidate.binding === entry.binding);
|
|
159
|
+
if (existing) Object.assign(existing, entry);
|
|
160
|
+
else stanza.secrets_store_secrets.push({ ...entry });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Write one Worker's stanza, and hand back **the path that was written** — the tracked `wrangler.jsonc`
|
|
167
|
+
* or the generated artifact, as the scope decided. The caller reports it, so what a run says it wrote is
|
|
168
|
+
* what the writer wrote rather than a second computation of the same rule (#251).
|
|
169
|
+
*/
|
|
170
|
+
export async function applyProvisionedEnv(options: {
|
|
171
|
+
/** The Worker's directory — the one holding the `wrangler.jsonc` to edit. */
|
|
172
|
+
workerDir: string;
|
|
173
|
+
/** The Worker's deploy name, from its own `wrangler.jsonc`. `scope.worker` turns it into this scope's. */
|
|
174
|
+
worker: string;
|
|
175
|
+
/** The scope: both the stanza written into and the names written in. */
|
|
176
|
+
scope: ProvisionScope;
|
|
177
|
+
/** Only the resources this Worker's own config declares. */
|
|
178
|
+
resources: readonly FeatureResource[];
|
|
179
|
+
/** Only the service bindings this Worker's own config declares, already resolved to this scope. */
|
|
180
|
+
services: readonly ServiceEntry[];
|
|
181
|
+
/**
|
|
182
|
+
* The `secrets_store_secrets` entries this Worker's own registry declares, named for this scope.
|
|
183
|
+
*
|
|
184
|
+
* This is the stanza `pithy add` deliberately could not write and nothing came back for (#238, #239).
|
|
185
|
+
* It is complete by construction — every entry carries its `store_id` and `secret_name` — because a
|
|
186
|
+
* partial one does not degrade: wrangler refuses the whole config.
|
|
187
|
+
*/
|
|
188
|
+
secrets: readonly SecretStoreBinding[];
|
|
189
|
+
}): Promise<string> {
|
|
190
|
+
const destination = await editStanza(options.workerDir, options.scope.stanza, options.scope.source, (stanza) => {
|
|
191
|
+
stanza.name = options.scope.worker(options.worker);
|
|
192
|
+
for (const resource of options.resources) {
|
|
193
|
+
const { array, fields } = KIND_TO_WRANGLER[resource.kind];
|
|
194
|
+
// Reuse the existing comment-json array (preserving its comments) or start a fresh one, then
|
|
195
|
+
// mutate in place — never replace it with a filtered plain array, which would strip
|
|
196
|
+
// comment-json's symbols.
|
|
197
|
+
stanza[array] ??= [];
|
|
198
|
+
upsertByBinding(stanza[array], resource.binding, fields(resource));
|
|
199
|
+
}
|
|
200
|
+
if (options.services.length > 0) {
|
|
201
|
+
stanza.services ??= [];
|
|
202
|
+
for (const entry of options.services) {
|
|
203
|
+
const existing = stanza.services.find((candidate) => candidate.binding === entry.binding);
|
|
204
|
+
if (existing) existing.service = entry.service;
|
|
205
|
+
else stanza.services.push({ ...entry });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
if (options.secrets.length > 0) {
|
|
210
|
+
await editStanza(options.workerDir, options.scope.stanza, options.scope.source, (stanza) => {
|
|
211
|
+
stanza.secrets_store_secrets ??= [];
|
|
212
|
+
for (const entry of options.secrets) {
|
|
213
|
+
const existing = stanza.secrets_store_secrets.find((candidate) => candidate.binding === entry.binding);
|
|
214
|
+
if (existing) Object.assign(existing, entry);
|
|
215
|
+
else stanza.secrets_store_secrets.push({ ...entry });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return destination;
|
|
220
|
+
}
|