@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,425 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpec } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { isValidEnvironment } from "@pithy-sh/core/src/naming/environment";
|
|
6
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
7
|
+
import { workflowScriptName } from "@pithy-sh/core/src/workflow/naming";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One writer for the `wrangler.jsonc` entry a capability's binding needs, and one reader for whether a
|
|
11
|
+
* stanza already has it.
|
|
12
|
+
*
|
|
13
|
+
* `capabilities/add.ts` and `capabilities/reconcile.ts` each carried their own copy of both, "kept in
|
|
14
|
+
* lockstep by intent" — and intent is not a mechanism. They already disagreed: `add` stamped a spec's
|
|
15
|
+
* `remote` flag and wrote the Workers AI binding, `reconcile` did neither, so the same manifest produced
|
|
16
|
+
* two different configs depending on whether the capability arrived through `pithy add` or through
|
|
17
|
+
* `pithy upgrade`. This module is the mechanism, so the drift has nowhere to live.
|
|
18
|
+
*
|
|
19
|
+
* The rule for what belongs here is {@link isWrittenBinding}: a kind is written when every field
|
|
20
|
+
* wrangler's validator requires is derivable offline. Everything else — a Vectorize `index_name`, a
|
|
21
|
+
* Secrets Store entry — is a provisioner's to write, and a partial entry would be worse than none
|
|
22
|
+
* because wrangler refuses to load the file at all.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A binding entry as wrangler files it: the name, plus whatever the spec can state offline. */
|
|
26
|
+
interface BindingEntry {
|
|
27
|
+
binding: string;
|
|
28
|
+
database_name?: string;
|
|
29
|
+
remote?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A single Durable Object namespace binding, as wrangler writes it. */
|
|
33
|
+
interface DurableObjectBinding {
|
|
34
|
+
name: string;
|
|
35
|
+
class_name: string;
|
|
36
|
+
remote?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One `ratelimits` entry. `name` is the binding — wrangler spells this one differently from every other
|
|
41
|
+
* array, which is its own reason for there being a single writer.
|
|
42
|
+
*/
|
|
43
|
+
interface RateLimitBinding {
|
|
44
|
+
name: string;
|
|
45
|
+
namespace_id: string;
|
|
46
|
+
simple: { limit: number; period: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One `workflows` entry on the **app** Worker: a cross-script binding into the capability's host. */
|
|
50
|
+
interface WorkflowBindingEntry {
|
|
51
|
+
binding: string;
|
|
52
|
+
name: string;
|
|
53
|
+
class_name: string;
|
|
54
|
+
script_name: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A Durable Object class migration — a versioned tag registering (or dropping) DO classes. */
|
|
58
|
+
interface DurableObjectMigration {
|
|
59
|
+
tag: string;
|
|
60
|
+
new_sqlite_classes?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The binding arrays of one wrangler stanza — every key this module touches. `durable_objects.bindings`
|
|
65
|
+
* is per-environment (each environment gets its own DO namespace); DO class `migrations` are **top-level
|
|
66
|
+
* only** (they register the class against the script, not per-environment), so they live on the root
|
|
67
|
+
* config — see {@link appendDurableObjectMigrations}.
|
|
68
|
+
*
|
|
69
|
+
* `ai` is a single object, not an array: a Worker has exactly one Workers AI binding.
|
|
70
|
+
*/
|
|
71
|
+
export interface WranglerStanza {
|
|
72
|
+
d1_databases?: BindingEntry[];
|
|
73
|
+
kv_namespaces?: BindingEntry[];
|
|
74
|
+
r2_buckets?: BindingEntry[];
|
|
75
|
+
ai?: BindingEntry;
|
|
76
|
+
ratelimits?: RateLimitBinding[];
|
|
77
|
+
workflows?: WorkflowBindingEntry[];
|
|
78
|
+
durable_objects?: { bindings: DurableObjectBinding[] };
|
|
79
|
+
migrations?: DurableObjectMigration[];
|
|
80
|
+
env?: Record<string, WranglerStanza | undefined>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What one call to {@link appendBinding} did.
|
|
85
|
+
*
|
|
86
|
+
* **The writer answers "did I write it", because nothing else can.** `pithy upgrade` used to report the
|
|
87
|
+
* bindings its *plan* named and never asked the writer whether any of them landed, so five capabilities'
|
|
88
|
+
* Workflow bindings were counted as added, silently declined, and correctly reported missing by `pithy
|
|
89
|
+
* doctor` seconds later (#318). A count derived from an intention is a claim about the future; this is
|
|
90
|
+
* the return value that makes it a claim about the file.
|
|
91
|
+
*
|
|
92
|
+
* `skipped` carries a `reason` written for an operator reading a terminal, not for a log: it names what
|
|
93
|
+
* the entry needed and did not have, so the next step is obvious.
|
|
94
|
+
*/
|
|
95
|
+
export type BindingWrite =
|
|
96
|
+
/** An entry was appended to the stanza. `proposed` is set only for KV, whose title wrangler cannot hold. */
|
|
97
|
+
| { outcome: "written"; proposed?: ProposedName }
|
|
98
|
+
/** The stanza already declares this binding. Left exactly as the adopter has it. */
|
|
99
|
+
| { outcome: "present" }
|
|
100
|
+
/** A kind this module writes no array for — a `secret`, a `vectorize` index, a `queue`, an `email` send binding, a `service`. */
|
|
101
|
+
| { outcome: "unsupported" }
|
|
102
|
+
/** The entry could not be composed honestly. Nothing was written, and this says what was missing. */
|
|
103
|
+
| { outcome: "skipped"; reason: string };
|
|
104
|
+
|
|
105
|
+
/** A `<project>-<env>-<binding>` name proposed for a resource the adopter creates themselves. */
|
|
106
|
+
export interface ProposedName {
|
|
107
|
+
/** The Worker env binding the name is proposed for (e.g. `SESSIONS`). */
|
|
108
|
+
binding: string;
|
|
109
|
+
/** The environment whose stanza declares it — `dev` for the top-level one. */
|
|
110
|
+
env: string;
|
|
111
|
+
/** The proposed resource name, composed by the one naming rule: `<project>-<env>-<binding>`. */
|
|
112
|
+
name: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Which capability, project, and environment a stanza's entries are composed for. */
|
|
116
|
+
export interface BindingScope {
|
|
117
|
+
/** The project name, or absent when none was resolved — in which case nothing is proposed or derived. */
|
|
118
|
+
project?: string;
|
|
119
|
+
/** The environment this stanza *is* — `dev` for the top-level one, else the `env.<name>` key. */
|
|
120
|
+
env: string;
|
|
121
|
+
/** The capability the bindings belong to — the `<capability>` segment of a Workflow's deployed names. */
|
|
122
|
+
capability: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The prefix every DO class-migration tag Pithy allocates carries: `v1`, `v2`, … in add order.
|
|
127
|
+
*
|
|
128
|
+
* A tag names one step, and Cloudflare remembers the last one it applied — which is why the number
|
|
129
|
+
* matters, and why {@link appendDurableObjectMigrations} never reuses one.
|
|
130
|
+
*/
|
|
131
|
+
const DO_MIGRATION_TAG_PREFIX = "v";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The rate-limit policy a `ratelimit` binding is written with: **100 requests per 60 seconds, per client
|
|
135
|
+
* IP**.
|
|
136
|
+
*
|
|
137
|
+
* A tier-1 edge limiter is a flood guard, not a product rule — `@pithy-sh/auth`'s middleware says so
|
|
138
|
+
* itself ("the limit and window are set on the binding in `wrangler.jsonc`, not here"), and the per-action
|
|
139
|
+
* caps that *are* a product rule live in Better Auth's D1 limiter one tier down. So the number belongs in
|
|
140
|
+
* the adopter's config where they can tune it, and the only question here is what it starts at.
|
|
141
|
+
*
|
|
142
|
+
* 100/60 is high enough that no human hits it and low enough to blunt credential stuffing. `period` is not
|
|
143
|
+
* free-form: Cloudflare accepts **10 or 60 only**, so a "sensible" 300 would be a config wrangler refuses.
|
|
144
|
+
*/
|
|
145
|
+
const RATE_LIMIT_REQUESTS = 100;
|
|
146
|
+
const RATE_LIMIT_PERIOD_SECONDS = 60;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The `namespace_id` for a rate limiter, derived from its binding name.
|
|
150
|
+
*
|
|
151
|
+
* Cloudflare has the adopter choose this, and it is the counter's identity: two bindings sharing one id
|
|
152
|
+
* share one budget. Deriving it from the binding name rather than counting entries is what makes the
|
|
153
|
+
* value **stable** — the same across environments, across a re-add, across `pithy upgrade` retrofitting an
|
|
154
|
+
* older project, and independent of the order capabilities were composed in. A positional counter would
|
|
155
|
+
* renumber every limiter the moment one was removed, silently merging two budgets.
|
|
156
|
+
*
|
|
157
|
+
* Four digits, from an FNV-1a hash: numeric because every Cloudflare example is, and bounded because the
|
|
158
|
+
* id is a label rather than an address. Two different bindings colliding would share one budget — a
|
|
159
|
+
* limiter that trips sooner than its own traffic explains — which is why it is worth saying that no
|
|
160
|
+
* shipped capability declares a second rate limiter, and that this is the line to revisit when one does.
|
|
161
|
+
*/
|
|
162
|
+
export function rateLimitNamespaceId(binding: string): string {
|
|
163
|
+
let hash = 0x811c9dc5;
|
|
164
|
+
for (let i = 0; i < binding.length; i++) {
|
|
165
|
+
hash ^= binding.charCodeAt(i);
|
|
166
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
167
|
+
}
|
|
168
|
+
return String(1000 + (hash % 9000));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Stamp the spec's `remote` flag onto an emitted entry. An unset `remote` writes no key at all: the
|
|
173
|
+
* capability has no opinion, and an explicit `remote: false` would pin the binding to local emulation the
|
|
174
|
+
* adopter may well want to override.
|
|
175
|
+
*/
|
|
176
|
+
function withRemote<Entry extends object>(entry: Entry, binding: BindingSpec): Entry {
|
|
177
|
+
return binding.remote === undefined ? entry : { ...entry, remote: binding.remote };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `<project>-<env>-<binding>` name to propose for a binding, or `undefined` when there is nothing safe
|
|
182
|
+
* to propose.
|
|
183
|
+
*
|
|
184
|
+
* **Through the facade, per namespace.** A D1 database and a KV namespace are different Cloudflare
|
|
185
|
+
* namespaces with different limits — no published cap and 512 respectively — and calling the generic
|
|
186
|
+
* composer held both to 63, which is R2's number and only R2's.
|
|
187
|
+
*
|
|
188
|
+
* Two cases propose nothing rather than guessing. No project name: a guessed prefix is worse than none,
|
|
189
|
+
* since every command that later recomputes the name would compute a different one. And an `env.<key>` the
|
|
190
|
+
* naming scheme does not accept — an eleven-character environment eats the room every project name was
|
|
191
|
+
* already accepted against, so no name for it is honest. The binding is still wired either way.
|
|
192
|
+
*/
|
|
193
|
+
function proposeName(scope: BindingScope, binding: string, kind: "d1" | "kv"): string | undefined {
|
|
194
|
+
if (scope.project === undefined || !isValidEnvironment(scope.env)) return undefined;
|
|
195
|
+
return resourceNames(scope.project).env(scope.env)[kind](binding);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The complete `workflows` entry for a binding, or `undefined` when it cannot be named honestly.
|
|
200
|
+
*
|
|
201
|
+
* Every field wrangler requires is derived rather than guessed: `name` is the deployed Workflow
|
|
202
|
+
* (`<project>-<env>-<capability>-<job>`, through core's own composer, so it is byte-identical to what the
|
|
203
|
+
* capability's provisioner deploys under), `class_name` is the exported `WorkflowEntrypoint` the manifest
|
|
204
|
+
* states, and `script_name` is the capability's host Worker — a cross-script binding, because the class
|
|
205
|
+
* lives in the host and never in the app.
|
|
206
|
+
*
|
|
207
|
+
* `script_name` goes through the naming facade's `worker` kind, so it is held to a Worker's 63 rather than
|
|
208
|
+
* a Workflow's 64. The one-character gap is real, and it is the one that survives an adopter enabling
|
|
209
|
+
* workers.dev. Same call `project/appBindings.ts` makes at provision time, so the two writers cannot
|
|
210
|
+
* disagree about the same Worker.
|
|
211
|
+
*
|
|
212
|
+
* **This writer has one shape, and it is the cross-Worker one.** A capability's Workflow class is
|
|
213
|
+
* exported by that capability's host Worker, never by the app, so there is no same-Worker branch here to
|
|
214
|
+
* be missing — #318 guessed there was. The same-Worker shape (`class_name`, no `script_name`) is a
|
|
215
|
+
* different writer for a different owner: `project/appWorkflows.ts`, for jobs the adopter's **own** app
|
|
216
|
+
* capability declares and its own script exports. Looking for it here finds a case this file will never
|
|
217
|
+
* see.
|
|
218
|
+
*
|
|
219
|
+
* A binding with no `job` or no `className` is not writable: wrangler rejects a `workflows` entry missing
|
|
220
|
+
* either, so emitting a partial one would trade a failed request for a config that will not load.
|
|
221
|
+
* `capabilities/requiredBindings.test.ts` is what makes that a build failure rather than a surprise.
|
|
222
|
+
*
|
|
223
|
+
* **Each refusal says why.** Returning a bare `undefined` is what let `pithy upgrade` count these as
|
|
224
|
+
* written (#318): the caller had nothing to distinguish "wrote it" from "declined", so it reported the
|
|
225
|
+
* plan. The reason is the operator's next step, in their words — the missing field, or the missing
|
|
226
|
+
* project name.
|
|
227
|
+
*/
|
|
228
|
+
function workflowEntry(scope: BindingScope, binding: BindingSpec): WorkflowBindingEntry | { reason: string } {
|
|
229
|
+
const { project, env, capability } = scope;
|
|
230
|
+
if (project === undefined) return { reason: "no project name is resolved" };
|
|
231
|
+
if (!isValidEnvironment(env)) return { reason: `the naming scheme does not accept the environment "${env}"` };
|
|
232
|
+
if (binding.job === undefined || binding.className === undefined) {
|
|
233
|
+
return { reason: `${binding.name} declares no ${binding.job === undefined ? "job" : "className"}` };
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
binding: binding.name,
|
|
237
|
+
name: workflowScriptName({ project, capability, job: binding.job, env }),
|
|
238
|
+
class_name: binding.className,
|
|
239
|
+
script_name: resourceNames(project).env(env).worker(capability),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether a stanza already declares a binding. `null` means the kind has no `wrangler.jsonc` array this
|
|
245
|
+
* module writes — a `secret`, a `vectorize` index, a `queue`, an `email` send binding, a `service` — so
|
|
246
|
+
* there is nothing to look for and nothing to report missing.
|
|
247
|
+
*
|
|
248
|
+
* The presence check keys on the **binding name alone**, deliberately. Comparing the derived value too
|
|
249
|
+
* would read an adopter's renamed database, retuned rate limit, or repointed Workflow as a missing
|
|
250
|
+
* binding, and the writer would append a second entry for a binding wrangler already has.
|
|
251
|
+
*/
|
|
252
|
+
export function stanzaHasBinding(stanza: WranglerStanza, binding: BindingSpec): boolean | null {
|
|
253
|
+
switch (binding.type) {
|
|
254
|
+
case "d1":
|
|
255
|
+
return (stanza.d1_databases ?? []).some((entry) => entry.binding === binding.name);
|
|
256
|
+
case "kv":
|
|
257
|
+
return (stanza.kv_namespaces ?? []).some((entry) => entry.binding === binding.name);
|
|
258
|
+
case "r2":
|
|
259
|
+
return (stanza.r2_buckets ?? []).some((entry) => entry.binding === binding.name);
|
|
260
|
+
case "ai":
|
|
261
|
+
return stanza.ai !== undefined;
|
|
262
|
+
case "durable_object":
|
|
263
|
+
return (stanza.durable_objects?.bindings ?? []).some((entry) => entry.name === binding.name);
|
|
264
|
+
case "ratelimit":
|
|
265
|
+
return (stanza.ratelimits ?? []).some((entry) => entry.name === binding.name);
|
|
266
|
+
case "workflow":
|
|
267
|
+
return (stanza.workflows ?? []).some((entry) => entry.binding === binding.name);
|
|
268
|
+
default:
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Append one binding's entry to a stanza, in place, unless the binding is already there.
|
|
275
|
+
*
|
|
276
|
+
* The writer emits a binding's **shape** — its name, and whatever the spec and the naming rule can state
|
|
277
|
+
* offline. Resource identity that only provisioning knows stays absent: `database_id`, `id`, and
|
|
278
|
+
* `bucket_name` are filled later by `pithy provision` or the capability's own provisioner. `database_name`
|
|
279
|
+
* is the exception and it is a *proposal*, not an identity — wrangler accepts a D1 entry naming a database
|
|
280
|
+
* that does not exist yet, and an adopter who leaves it alone gets a project-scoped name instead of
|
|
281
|
+
* inventing `db`.
|
|
282
|
+
*
|
|
283
|
+
* Returns {@link BindingWrite} — **what happened**, not what was meant to. The KV namespace title rides
|
|
284
|
+
* on a `written` outcome, when this call wrote a KV binding and a name could be composed: **KV is
|
|
285
|
+
* reported rather than written, because wrangler has nowhere to write it**, since a `kv_namespaces` entry
|
|
286
|
+
* takes `binding`, `id`, `preview_id`, and `remote`, with no title field, so the name lives only in the
|
|
287
|
+
* account.
|
|
288
|
+
*/
|
|
289
|
+
export function appendBinding(stanza: WranglerStanza, binding: BindingSpec, scope: BindingScope): BindingWrite {
|
|
290
|
+
const has = stanzaHasBinding(stanza, binding);
|
|
291
|
+
if (has === null) return { outcome: "unsupported" };
|
|
292
|
+
if (has) return { outcome: "present" };
|
|
293
|
+
|
|
294
|
+
switch (binding.type) {
|
|
295
|
+
case "d1": {
|
|
296
|
+
stanza.d1_databases ??= [];
|
|
297
|
+
const name = proposeName(scope, binding.name, "d1");
|
|
298
|
+
stanza.d1_databases.push(
|
|
299
|
+
withRemote({ binding: binding.name, ...(name ? { database_name: name } : {}) }, binding),
|
|
300
|
+
);
|
|
301
|
+
return { outcome: "written" };
|
|
302
|
+
}
|
|
303
|
+
case "kv": {
|
|
304
|
+
stanza.kv_namespaces ??= [];
|
|
305
|
+
stanza.kv_namespaces.push(withRemote({ binding: binding.name }, binding));
|
|
306
|
+
const name = proposeName(scope, binding.name, "kv");
|
|
307
|
+
return name
|
|
308
|
+
? { outcome: "written", proposed: { binding: binding.name, env: scope.env, name } }
|
|
309
|
+
: { outcome: "written" };
|
|
310
|
+
}
|
|
311
|
+
case "r2":
|
|
312
|
+
stanza.r2_buckets ??= [];
|
|
313
|
+
stanza.r2_buckets.push(withRemote({ binding: binding.name }, binding));
|
|
314
|
+
return { outcome: "written" };
|
|
315
|
+
case "ai":
|
|
316
|
+
// A Worker gets exactly one Workers AI binding, so `ai` is an object rather than an array. An
|
|
317
|
+
// existing one is left alone by the presence check above: it is either this capability's
|
|
318
|
+
// (idempotency) or an adopter's deliberate choice of binding name, and clobbering either would
|
|
319
|
+
// break their Worker.
|
|
320
|
+
stanza.ai = withRemote({ binding: binding.name }, binding);
|
|
321
|
+
return { outcome: "written" };
|
|
322
|
+
case "durable_object":
|
|
323
|
+
// `BindingSpec` refuses a classless DO binding at define and at manifest parse, so this is the
|
|
324
|
+
// second wall rather than the first. It still says why, because a writer that returns nothing
|
|
325
|
+
// teaches its caller to report the plan.
|
|
326
|
+
if (binding.className === undefined)
|
|
327
|
+
return { outcome: "skipped", reason: `${binding.name} declares no className` };
|
|
328
|
+
stanza.durable_objects ??= { bindings: [] };
|
|
329
|
+
stanza.durable_objects.bindings ??= [];
|
|
330
|
+
stanza.durable_objects.bindings.push(withRemote({ name: binding.name, class_name: binding.className }, binding));
|
|
331
|
+
return { outcome: "written" };
|
|
332
|
+
case "ratelimit":
|
|
333
|
+
stanza.ratelimits ??= [];
|
|
334
|
+
stanza.ratelimits.push({
|
|
335
|
+
name: binding.name,
|
|
336
|
+
namespace_id: rateLimitNamespaceId(binding.name),
|
|
337
|
+
simple: { limit: RATE_LIMIT_REQUESTS, period: RATE_LIMIT_PERIOD_SECONDS },
|
|
338
|
+
});
|
|
339
|
+
return { outcome: "written" };
|
|
340
|
+
case "workflow": {
|
|
341
|
+
const entry = workflowEntry(scope, binding);
|
|
342
|
+
if ("reason" in entry) return { outcome: "skipped", reason: entry.reason };
|
|
343
|
+
stanza.workflows ??= [];
|
|
344
|
+
stanza.workflows.push(entry);
|
|
345
|
+
return { outcome: "written" };
|
|
346
|
+
}
|
|
347
|
+
default:
|
|
348
|
+
// `queue`, `email`, `secret`, `service`, and `vectorize`. Nothing is emitted, and for two different
|
|
349
|
+
// reasons: a `secret` has no wrangler array at all, and a `vectorize` entry needs the provisioned
|
|
350
|
+
// `index_name` — wrangler's validator requires it, so a binding-only entry stops `wrangler dev` and
|
|
351
|
+
// `wrangler deploy` both. `capabilities/requiredBindings.test.ts` fails any capability that requires
|
|
352
|
+
// a kind neither this writer nor a provisioner covers. Unreachable: `stanzaHasBinding` already
|
|
353
|
+
// returned `null` for every one of them above.
|
|
354
|
+
return { outcome: "unsupported" };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Register a capability's Durable Object classes in the **top-level** `migrations` array — the class
|
|
360
|
+
* migration tag, distinct from D1 (Kysely) migrations and from the per-environment DO bindings.
|
|
361
|
+
*
|
|
362
|
+
* `new_sqlite_classes`, not `new_classes`, deliberately: a Pithy session object stores its state in
|
|
363
|
+
* SQLite-backed DO storage, and `new_classes` would provision a key-value backend that silently cannot run
|
|
364
|
+
* SQL — the well-known DO footgun.
|
|
365
|
+
*
|
|
366
|
+
* **Every add that brings new classes allocates its own tag, and no tag is ever edited after it is
|
|
367
|
+
* written.** A tag is applied once and remembered by Cloudflare: the next deploy sends only the tags
|
|
368
|
+
* *after* the last one applied. So a class appended into a tag that has already been deployed is sent to
|
|
369
|
+
* nobody — the namespace is never created, and the deploy fails on a binding to a class with no migration
|
|
370
|
+
* behind it. Every class used to merge into a single `v1`, and the comment here said why that was safe:
|
|
371
|
+
* `@pithy-sh/multiplayer` was the only capability shipping a Durable Object, so every add was a first add.
|
|
372
|
+
* #415 made `@pithy-sh/matchmaking` addable, which ends that — and `pithy add multiplayer` → `pithy deploy`
|
|
373
|
+
* → `pithy add matchmaking` is the path its own README recommends.
|
|
374
|
+
*
|
|
375
|
+
* Splitting costs a never-deployed Worker nothing: wrangler applies `v1` then `v2` in order on the first
|
|
376
|
+
* deploy, reaching the identical end state. **Idempotent**, because a class already named by *any* tag is
|
|
377
|
+
* not new — so a re-run allocates nothing and rewrites nothing, which is what `pithy add`'s contract and
|
|
378
|
+
* `pithy upgrade`'s reconcile both depend on.
|
|
379
|
+
*/
|
|
380
|
+
export function appendDurableObjectMigrations(config: WranglerStanza, bindings: readonly BindingSpec[]): void {
|
|
381
|
+
const classes = bindings
|
|
382
|
+
.filter((binding) => binding.type === "durable_object" && binding.className !== undefined)
|
|
383
|
+
.map((binding) => binding.className as string);
|
|
384
|
+
if (classes.length === 0) return;
|
|
385
|
+
|
|
386
|
+
config.migrations ??= [];
|
|
387
|
+
// A class any existing tag already names is registered, whether or not that tag has been deployed yet.
|
|
388
|
+
// This is the whole of the idempotency: a second `pithy add` of the same capability finds nothing new
|
|
389
|
+
// and returns without touching the file.
|
|
390
|
+
const registered = new Set(config.migrations.flatMap((migration) => migration.new_sqlite_classes ?? []));
|
|
391
|
+
const fresh = [...new Set(classes)].filter((className) => !registered.has(className));
|
|
392
|
+
if (fresh.length === 0) return;
|
|
393
|
+
|
|
394
|
+
config.migrations.push({ tag: nextMigrationTag(config.migrations), new_sqlite_classes: fresh });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The next unused `v<n>` tag, counting past whatever is already there.
|
|
399
|
+
*
|
|
400
|
+
* Counting rather than incrementing the last one, because a tag an adopter wrote by hand need not be
|
|
401
|
+
* `v<n>` at all, and a collision would silently merge this step into theirs — the exact failure the split
|
|
402
|
+
* exists to prevent. The loop terminates: each pass tries a strictly larger number against a finite set.
|
|
403
|
+
*/
|
|
404
|
+
function nextMigrationTag(migrations: readonly DurableObjectMigration[]): string {
|
|
405
|
+
const taken = new Set(migrations.map((migration) => migration.tag));
|
|
406
|
+
let n = migrations.length + 1;
|
|
407
|
+
while (taken.has(`${DO_MIGRATION_TAG_PREFIX}${n}`)) n += 1;
|
|
408
|
+
return `${DO_MIGRATION_TAG_PREFIX}${n}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Every environment's binding stanza, paired with the environment it *is*: the top-level one (the dev
|
|
413
|
+
* environment) plus each `env.<name>`.
|
|
414
|
+
*
|
|
415
|
+
* The pairing is the point. Both writers used to walk `Object.values(config.env)` and drop the key, which
|
|
416
|
+
* was harmless while an entry carried nothing but its binding — and wrong the moment a name has the
|
|
417
|
+
* environment in it, which is every proposed database name and every Workflow entry.
|
|
418
|
+
*/
|
|
419
|
+
export function envStanzas(config: WranglerStanza): { env: string; stanza: WranglerStanza }[] {
|
|
420
|
+
const list: { env: string; stanza: WranglerStanza }[] = [{ env: "dev", stanza: config }];
|
|
421
|
+
for (const [env, stanza] of Object.entries(config.env ?? {})) {
|
|
422
|
+
if (stanza) list.push({ env, stanza });
|
|
423
|
+
}
|
|
424
|
+
return list;
|
|
425
|
+
}
|