@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,184 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
6
|
+
import { hostWorkflowsFor } from "@pithy-sh/core/src/workflow/host";
|
|
7
|
+
import type { WorkflowHostNameParts } from "@pithy-sh/core/src/workflow/naming";
|
|
8
|
+
import type { WorkflowRegistry } from "@pithy-sh/core/src/workflow/spec";
|
|
9
|
+
import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The bindings a capability's **provisioner** writes into the app's `wrangler.jsonc` — the ones
|
|
13
|
+
* `pithy add` deliberately cannot write.
|
|
14
|
+
*
|
|
15
|
+
* Wrangler validates its config before it does anything else, and two binding kinds carry required
|
|
16
|
+
* fields whose values only exist after provisioning: a `vectorize` entry must name an `index_name`,
|
|
17
|
+
* and a `workflows` entry must carry a `name` and a `class_name`. `pithy add` is offline — it knows
|
|
18
|
+
* neither the provisioned index name nor the per-environment Workflow script name — so an `add` that
|
|
19
|
+
* emitted a partial entry would leave the adopter with a `wrangler.jsonc` that fails to load. It
|
|
20
|
+
* emits nothing instead, and this module completes the entry once `pithy <capability> provision` has
|
|
21
|
+
* stood the resources up. `capabilities/add.ts` states the same contract from the other side.
|
|
22
|
+
*
|
|
23
|
+
* `dev` writes the top-level stanza and every other environment writes its own `env.<name>` — the same
|
|
24
|
+
* rule `commands/vector.ts` reads the app database id by and writes `VECTOR_PROVISIONED` by. Wrangler
|
|
25
|
+
* does not inherit bindings into a named environment, and every name written here is
|
|
26
|
+
* environment-scoped anyway. Upsert by binding name, comment-preserving, idempotent: re-provisioning
|
|
27
|
+
* rewrites the same entries in place.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One `workflows` entry, complete. `binding` and `class_name` are code references; `name` is the
|
|
32
|
+
* deployed Workflow and `script_name` the host Worker that runs it — both environment-scoped, which
|
|
33
|
+
* is exactly why they arrive here rather than at add time.
|
|
34
|
+
*/
|
|
35
|
+
export interface AppWorkflowBinding {
|
|
36
|
+
/** The binding name the Worker env exposes, e.g. `STORAGE_SWEEP`. */
|
|
37
|
+
binding: string;
|
|
38
|
+
/** The deployed Workflow name, `<project>-<env>-<capability>-<job>`. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** The exported `WorkflowEntrypoint` subclass that runs the job. */
|
|
41
|
+
class_name: string;
|
|
42
|
+
/** The host Worker the class lives in, `<project>-<env>-<capability>` — this is a cross-script binding. */
|
|
43
|
+
script_name: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One `vectorize` entry, complete. `index_name` is the provisioned index, `<project>-<env>-vector-<index>`. */
|
|
47
|
+
export interface AppVectorizeBinding {
|
|
48
|
+
/** The binding name the Worker env exposes, e.g. `VECTORIZE`. */
|
|
49
|
+
binding: string;
|
|
50
|
+
/** The provisioned Vectorize index this binding addresses. */
|
|
51
|
+
index_name: string;
|
|
52
|
+
/** Reach the real index in local dev. Vectorize has no local emulation, so this is always true. */
|
|
53
|
+
remote?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The provisioner-owned slice of one `env.<env>` stanza. */
|
|
57
|
+
interface AppBindingStanza {
|
|
58
|
+
workflows?: AppWorkflowBinding[];
|
|
59
|
+
vectorize?: AppVectorizeBinding[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The bindings one provisioning run wrote for one environment. Either list may be omitted. */
|
|
63
|
+
export interface AppBindings {
|
|
64
|
+
/** Complete `workflows` entries, one per hosted job. */
|
|
65
|
+
workflows?: readonly AppWorkflowBinding[];
|
|
66
|
+
/** Complete `vectorize` entries, one per configured index. */
|
|
67
|
+
vectorize?: readonly AppVectorizeBinding[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Upsert entries into a binding array **in place**. comment-json stores an array's comments as
|
|
72
|
+
* symbol-keyed properties on the array object itself, so replacing the array with a fresh one would
|
|
73
|
+
* silently delete the adopter's notes — the rule `capabilities/remove.ts` and
|
|
74
|
+
* `feature/wranglerEnv.ts` both document.
|
|
75
|
+
*/
|
|
76
|
+
function upsertByBinding<Entry extends { binding: string }>(entries: Entry[], incoming: readonly Entry[]): void {
|
|
77
|
+
for (const entry of incoming) {
|
|
78
|
+
const index = entries.findIndex((candidate) => candidate.binding === entry.binding);
|
|
79
|
+
if (index === -1) entries.push(entry);
|
|
80
|
+
else entries[index] = entry;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The fields wrangler's config validator **requires**, per binding kind. An entry missing one of these
|
|
86
|
+
* does not degrade — it fails validation, and `wrangler dev` and `wrangler deploy` both refuse to run.
|
|
87
|
+
* Stated here once, so the writer and its tests check the same list.
|
|
88
|
+
*/
|
|
89
|
+
const REQUIRED_FIELDS: Record<"workflows" | "vectorize", readonly string[]> = {
|
|
90
|
+
workflows: ["binding", "name", "class_name"],
|
|
91
|
+
vectorize: ["binding", "index_name"],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every `workflows`/`vectorize` entry in one stanza that wrangler would reject, described. Empty means
|
|
96
|
+
* the stanza loads. Exported because "does this config still load" is the property the writer promises,
|
|
97
|
+
* and a promise worth making is worth asserting.
|
|
98
|
+
*/
|
|
99
|
+
export function incompleteBindings(stanza: unknown): string[] {
|
|
100
|
+
if (typeof stanza !== "object" || stanza === null) return [];
|
|
101
|
+
const problems: string[] = [];
|
|
102
|
+
for (const [key, required] of Object.entries(REQUIRED_FIELDS)) {
|
|
103
|
+
const entries = (stanza as Record<string, unknown>)[key];
|
|
104
|
+
if (!Array.isArray(entries)) continue;
|
|
105
|
+
entries.forEach((entry: unknown, index: number) => {
|
|
106
|
+
const record = typeof entry === "object" && entry !== null ? (entry as Record<string, unknown>) : {};
|
|
107
|
+
const missing = required.filter((field) => typeof record[field] !== "string" || record[field] === "");
|
|
108
|
+
if (missing.length > 0) {
|
|
109
|
+
const named = typeof record.binding === "string" ? record.binding : `#${index}`;
|
|
110
|
+
problems.push(`${key}[${named}] is missing ${missing.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return problems;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a capability's provisioned bindings into the project's `wrangler.jsonc`, for one environment.
|
|
119
|
+
*
|
|
120
|
+
* Every entry written here is complete by construction — the types demand each field wrangler
|
|
121
|
+
* requires — and the stanza is checked before the file is written, so a `wrangler.jsonc` this function
|
|
122
|
+
* touched always loads. Idempotent and comment-preserving.
|
|
123
|
+
*/
|
|
124
|
+
export async function applyAppBindings(projectDir: string, env: string, bindings: AppBindings): Promise<void> {
|
|
125
|
+
if (!bindings.workflows?.length && !bindings.vectorize?.length) return;
|
|
126
|
+
|
|
127
|
+
const config = (await readWranglerConfig(projectDir)) as AppBindingStanza & {
|
|
128
|
+
env?: Record<string, AppBindingStanza | undefined>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
let stanza: AppBindingStanza = config;
|
|
132
|
+
if (env !== "dev") {
|
|
133
|
+
config.env ??= {};
|
|
134
|
+
stanza = config.env[env] ?? {};
|
|
135
|
+
config.env[env] = stanza;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (bindings.workflows?.length) {
|
|
139
|
+
stanza.workflows ??= [];
|
|
140
|
+
upsertByBinding(stanza.workflows, bindings.workflows);
|
|
141
|
+
}
|
|
142
|
+
if (bindings.vectorize?.length) {
|
|
143
|
+
stanza.vectorize ??= [];
|
|
144
|
+
upsertByBinding(stanza.vectorize, bindings.vectorize);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Never write a config wrangler will not load. A hand-edited entry that lost a field lands here too,
|
|
148
|
+
// which is the right place to hear about it — before the next deploy.
|
|
149
|
+
const problems = incompleteBindings(stanza);
|
|
150
|
+
if (problems.length > 0) {
|
|
151
|
+
throw new InternalError({
|
|
152
|
+
message: "wrangler.jsonc would not load with these bindings.",
|
|
153
|
+
action: `Fix the ${env} bindings in wrangler.jsonc by hand, then re-run provision.`,
|
|
154
|
+
detail: problems.join("; "),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await writeWranglerConfig(projectDir, config);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The complete `workflows` entries for one capability in one environment, derived from its own
|
|
163
|
+
* registry. Thin over core's {@link hostWorkflowsFor}, which already computes the deployed Workflow
|
|
164
|
+
* name from the spec — the app's entry is the host's plus `script_name`, because the class lives in
|
|
165
|
+
* the host Worker rather than the app's own script.
|
|
166
|
+
*
|
|
167
|
+
* Every job the capability owns, which is right when the app dispatches all of them (storage, vector).
|
|
168
|
+
* A capability whose host self-fires a job the app must never bind — `@pithy-sh/email`'s scheduler —
|
|
169
|
+
* filters the result against its manifest's `requiredBindings` before writing.
|
|
170
|
+
*
|
|
171
|
+
* Takes the same `{ project, capability, env }` object the naming seam does. `project` comes from
|
|
172
|
+
* `requireProjectName`, never `resolveProjectName`: these two names must match the ones the capability's
|
|
173
|
+
* provisioner deployed under byte for byte, and a guessed project would write a `script_name` pointing
|
|
174
|
+
* at a Worker that does not exist — a config that loads and a dispatch that fails at runtime.
|
|
175
|
+
*/
|
|
176
|
+
export function appWorkflowBindings(registry: WorkflowRegistry, parts: WorkflowHostNameParts): AppWorkflowBinding[] {
|
|
177
|
+
// Through the facade, so the host script is held to a Worker's 63 rather than a Workflow's 64 — the
|
|
178
|
+
// one-character gap is real, and it is the one that survives an adopter enabling workers.dev.
|
|
179
|
+
const scriptName = resourceNames(parts.project).env(parts.env).worker(parts.capability);
|
|
180
|
+
return hostWorkflowsFor(registry, parts).workflows.map((entry) => ({
|
|
181
|
+
...entry,
|
|
182
|
+
script_name: scriptName,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { hostWorkflowsFor } from "@pithy-sh/core/src/workflow/host";
|
|
7
|
+
import { composeWorkflows } from "@pithy-sh/core/src/workflow/register";
|
|
8
|
+
import { stringify } from "comment-json";
|
|
9
|
+
import { incompleteBindings } from "./appBindings";
|
|
10
|
+
import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Workflows the adopter's **own app capability** declares, reconciled into that Worker's `wrangler.jsonc`.
|
|
14
|
+
*
|
|
15
|
+
* A library capability's Workflows already have a path: `pithy <capability> provision` deploys the host
|
|
16
|
+
* Worker and `project/appBindings.ts` writes the cross-script binding. Nothing did it for the app's own,
|
|
17
|
+
* so the `workflows` array, `triggers.crons`, and the per-environment repetition of both were hand-written
|
|
18
|
+
* — each entry having to match the kit's `<project>-<env>-<capability>-<job>` rule and Cloudflare's segment
|
|
19
|
+
* rule, which `workflowKey` asserts at assembly. A mistake therefore failed at deploy, not at the point of
|
|
20
|
+
* writing.
|
|
21
|
+
*
|
|
22
|
+
* The names come from core's own `hostWorkflowsFor`, so an app-declared job is named by exactly the code
|
|
23
|
+
* that names a library one. Nothing here formats a name.
|
|
24
|
+
*
|
|
25
|
+
* **What still belongs to the adopter: the class.** Cloudflare resolves a `class_name` in the script named
|
|
26
|
+
* by the binding, so the `WorkflowEntrypoint` subclass has to be exported from the Worker's `main`. That is
|
|
27
|
+
* five lines written once. The per-environment binding table is not.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One `workflows` entry for a job the app capability declares. It carries **no `script_name`**: the class
|
|
32
|
+
* lives in this Worker's own `main`, so the binding is same-script. A library capability's entry does carry
|
|
33
|
+
* one, and that difference is how {@link reconcileAppWorkflows} tells the two apart in a stanza it did not
|
|
34
|
+
* write alone.
|
|
35
|
+
*/
|
|
36
|
+
export interface AppOwnedWorkflow {
|
|
37
|
+
/** The binding name the Worker env exposes, e.g. `KEY_ROTATION`. */
|
|
38
|
+
binding: string;
|
|
39
|
+
/** The deployed Workflow name, `<project>-<env>-<capability>-<job>`. */
|
|
40
|
+
name: string;
|
|
41
|
+
/** The exported `WorkflowEntrypoint` subclass that runs the job. */
|
|
42
|
+
class_name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What one environment's stanza should say: the app's own `workflows` entries, and the crons that fire them. */
|
|
46
|
+
export interface AppWorkflowPlan {
|
|
47
|
+
/** Complete entries, one per job the app declares, in declaration order. */
|
|
48
|
+
workflows: AppOwnedWorkflow[];
|
|
49
|
+
/** The declared cron schedules, deduplicated. Empty when no job is scheduled. */
|
|
50
|
+
crons: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The identity an app-declared Workflow's name is composed from. */
|
|
54
|
+
export interface AppWorkflowNameParts {
|
|
55
|
+
/**
|
|
56
|
+
* The project name — the root `pithy.config.ts` `name`, from `requireProjectName` and never guessed.
|
|
57
|
+
* Workflow names are account-scoped, so a guessed project deploys under a name another project owns.
|
|
58
|
+
*/
|
|
59
|
+
project: string;
|
|
60
|
+
/** The target environment. `dev` is a real environment here: it names the local Workflow too. */
|
|
61
|
+
env: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What one environment's stanza should say, derived from the app capability alone.
|
|
66
|
+
*
|
|
67
|
+
* Thin over core's {@link hostWorkflowsFor}, which already refuses a job with no `className` and already
|
|
68
|
+
* composes the name — the deliberate point being that an app-declared job and a library-declared one get
|
|
69
|
+
* their names from one function. The host's entries are the app's minus `script_name`, so the extra field
|
|
70
|
+
* is dropped rather than a second name-composer being written.
|
|
71
|
+
*/
|
|
72
|
+
export function planAppWorkflows(app: Capability, parts: AppWorkflowNameParts): AppWorkflowPlan {
|
|
73
|
+
const registry = composeWorkflows([app]);
|
|
74
|
+
const { workflows, crons } = hostWorkflowsFor(registry, {
|
|
75
|
+
project: parts.project,
|
|
76
|
+
capability: app.name,
|
|
77
|
+
env: parts.env,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
workflows: workflows.map(({ binding, name, class_name }) => ({ binding, name, class_name })),
|
|
81
|
+
// Two jobs on one schedule are one cron: the Worker has a single `scheduled` handler and it fires
|
|
82
|
+
// every scheduled job on any tick, so a repeated expression is a duplicated run, not a second job.
|
|
83
|
+
crons: [...new Set(crons)],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The wrangler slice this module reads and writes. Unknown keys survive untouched — comment-json holds them. */
|
|
88
|
+
export interface WorkflowStanza {
|
|
89
|
+
workflows?: (AppOwnedWorkflow & { script_name?: string })[];
|
|
90
|
+
triggers?: { crons?: string[] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The whole config: the top-level stanza (wrangler's default environment) plus each named one. */
|
|
94
|
+
export interface WorkflowConfig extends WorkflowStanza {
|
|
95
|
+
env?: Record<string, WorkflowStanza | undefined>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Is this `workflows` entry the app capability's own?
|
|
100
|
+
*
|
|
101
|
+
* **One predicate, because the writer and the reader have to mean the same thing by it.** An entry
|
|
102
|
+
* carrying a `script_name` is a library capability's, written by that capability's provisioner and
|
|
103
|
+
* pointing at its host Worker. Everything else is same-script, which in this Worker means app-declared —
|
|
104
|
+
* that is what lets {@link reconcileAppWorkflows} replace the whole set rather than upsert it, and it is
|
|
105
|
+
* what `project/workflows.ts` reads back to ask whether the stanza binds what the app declares. Two
|
|
106
|
+
* copies of this rule would be two answers to "whose entry is this?", and drift the check could not see.
|
|
107
|
+
*/
|
|
108
|
+
function isAppOwned(entry: { script_name?: string }): boolean {
|
|
109
|
+
return entry.script_name === undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The app's own entries in one stanza's `workflows` table — the table {@link reconcileAppWorkflows}
|
|
114
|
+
* replaces, and the one the doctor and deploy readers compare against the declaration.
|
|
115
|
+
*
|
|
116
|
+
* The extra `script_name` field is not carried, because by definition these have none: the shape returned
|
|
117
|
+
* is exactly {@link planAppWorkflows}'s, so the comparison is between two values of one type.
|
|
118
|
+
*/
|
|
119
|
+
export function appOwnedWorkflows(stanza: WorkflowStanza | undefined): AppOwnedWorkflow[] {
|
|
120
|
+
return (stanza?.workflows ?? [])
|
|
121
|
+
.filter(isAppOwned)
|
|
122
|
+
.map(({ binding, name, class_name }) => ({ binding, name, class_name }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Options for {@link reconcileAppWorkflows}. */
|
|
126
|
+
export interface ReconcileAppWorkflowsOptions {
|
|
127
|
+
/** The Worker's directory — `apps/<name>`, where its `wrangler.jsonc` lives. */
|
|
128
|
+
workerDir: string;
|
|
129
|
+
/** The project name, from `requireProjectName`. */
|
|
130
|
+
project: string;
|
|
131
|
+
/** The Worker's own app capability — the one whose `workflows` map is the source of truth. */
|
|
132
|
+
app: Capability;
|
|
133
|
+
/** Narrow to one environment. Omitted reconciles the top-level stanza and every `env.<name>` already declared. */
|
|
134
|
+
env?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** What one environment's reconciliation did. */
|
|
138
|
+
export interface AppWorkflowRun {
|
|
139
|
+
/** The environment reconciled — `dev` for the top-level stanza. */
|
|
140
|
+
env: string;
|
|
141
|
+
/** The entries its `workflows` table now declares for the app, verbatim as written. */
|
|
142
|
+
workflows: AppOwnedWorkflow[];
|
|
143
|
+
/** The cron schedules its `triggers` now carries. */
|
|
144
|
+
crons: string[];
|
|
145
|
+
/** Whether anything moved. False on a re-run with nothing to change. */
|
|
146
|
+
changed: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The environments a run visits: the one named, else the top-level stanza plus every `env.<name>` the
|
|
151
|
+
* Worker already declares.
|
|
152
|
+
*
|
|
153
|
+
* Deriving the set from the file is the point — the defect was writing the same table once per environment
|
|
154
|
+
* by hand, so an adopter who adds `prod` later must not have to remember this command's argument list. An
|
|
155
|
+
* explicit `--env` still creates a stanza that is not there yet, because naming one is asking for it.
|
|
156
|
+
*/
|
|
157
|
+
function environmentsOf(config: WorkflowConfig, env: string | undefined): string[] {
|
|
158
|
+
if (env !== undefined) return [env];
|
|
159
|
+
return ["dev", ...Object.keys(config.env ?? {})];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The stanza for an environment, created when absent. `dev` is the top-level one — wrangler has no `env.dev`. */
|
|
163
|
+
function stanzaFor(config: WorkflowConfig, env: string): WorkflowStanza {
|
|
164
|
+
if (env === "dev") return config;
|
|
165
|
+
config.env ??= {};
|
|
166
|
+
const existing = config.env[env] ?? {};
|
|
167
|
+
config.env[env] = existing;
|
|
168
|
+
return existing;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replace the app's own entries in one stanza, leaving every provisioned one in place.
|
|
173
|
+
*
|
|
174
|
+
* An entry carrying a `script_name` is a library capability's, written by that capability's provisioner and
|
|
175
|
+
* pointing at its host Worker — untouchable here. Everything else is same-script, which in this Worker means
|
|
176
|
+
* app-declared, so the whole set is replaced rather than upserted: a job the app renamed or dropped must
|
|
177
|
+
* leave, and an upsert by binding name would strand it. The provisioned entries keep their positions ahead
|
|
178
|
+
* of the app's, so a re-run produces a byte-identical file.
|
|
179
|
+
*/
|
|
180
|
+
function replaceOwnWorkflows(stanza: WorkflowStanza, plan: AppWorkflowPlan): void {
|
|
181
|
+
const provisioned = (stanza.workflows ?? []).filter((entry) => !isAppOwned(entry));
|
|
182
|
+
const next = [...provisioned, ...plan.workflows];
|
|
183
|
+
if (stanza.workflows) {
|
|
184
|
+
// In place: comment-json keeps an array's comments as symbol-keyed properties on the array object,
|
|
185
|
+
// so a fresh array would silently drop the adopter's notes.
|
|
186
|
+
stanza.workflows.length = 0;
|
|
187
|
+
stanza.workflows.push(...next);
|
|
188
|
+
} else if (next.length > 0) {
|
|
189
|
+
stanza.workflows = next;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Set the stanza's cron schedule to what the app declares.
|
|
195
|
+
*
|
|
196
|
+
* Set, not merge. `createEntrypoint` gives a Worker one `scheduled` handler that starts **every** job
|
|
197
|
+
* carrying a schedule, whatever cron fired — so an expression nothing declares is not an extra job, it is
|
|
198
|
+
* every job running again at a time nobody asked for. The declaration is therefore the whole truth, and a
|
|
199
|
+
* schedule the adopter changes takes its old value with it.
|
|
200
|
+
*
|
|
201
|
+
* **An emptied schedule is written as `[]`, never as a deleted key.** Wrangler reads an absent `crons` as
|
|
202
|
+
* "not declared" and leaves the deployed Worker's schedule exactly as it was, so deleting the key when the
|
|
203
|
+
* app drops its last schedule would leave the old cron firing every job that remains — a reconcile that
|
|
204
|
+
* reports `Done.` and changes nothing where it matters. A stanza that never carried crons is still left
|
|
205
|
+
* alone, because a `triggers` block a project never had is noise in every config whose jobs all dispatch.
|
|
206
|
+
*/
|
|
207
|
+
function setCrons(stanza: WorkflowStanza, crons: string[]): void {
|
|
208
|
+
if (crons.length === 0 && stanza.triggers?.crons === undefined) return;
|
|
209
|
+
stanza.triggers ??= {};
|
|
210
|
+
stanza.triggers.crons = crons;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reconcile the app capability's declared Workflows and cron schedule into the Worker's `wrangler.jsonc` —
|
|
215
|
+
* the seam behind `pithy worker sync`.
|
|
216
|
+
*
|
|
217
|
+
* Idempotent, comment-preserving, and all-or-nothing: every environment is computed and checked before the
|
|
218
|
+
* file is written once, so a stanza wrangler would reject aborts the run rather than leaving half a config
|
|
219
|
+
* behind. An app that declares no Workflows writes nothing at all — including no empty `workflows` key,
|
|
220
|
+
* which wrangler reads as a declaration.
|
|
221
|
+
*
|
|
222
|
+
* **An app that declares none is still reconciled**, and that is not the same statement. It used to
|
|
223
|
+
* return before the file was opened, which made "the declaration is the truth" false in the one case
|
|
224
|
+
* where it matters most: drop the last job from `pithy.config.ts` and the binding and the cron stayed in
|
|
225
|
+
* `wrangler.jsonc` forever, with no command that would take them out and — since #267 — a doctor fault
|
|
226
|
+
* naming a command that could not answer it. The empty declaration is a declaration. What it writes is
|
|
227
|
+
* still nothing at all where there was nothing: `replaceOwnWorkflows` creates no `workflows` key and
|
|
228
|
+
* `setCrons` creates no `triggers` block, so a project that never had either is byte-identical after.
|
|
229
|
+
*/
|
|
230
|
+
export async function reconcileAppWorkflows(options: ReconcileAppWorkflowsOptions): Promise<AppWorkflowRun[]> {
|
|
231
|
+
const { workerDir, project, app, env } = options;
|
|
232
|
+
|
|
233
|
+
const config = (await readWranglerConfig(workerDir)) as WorkflowConfig;
|
|
234
|
+
const before = stringify(config);
|
|
235
|
+
|
|
236
|
+
const runs: AppWorkflowRun[] = [];
|
|
237
|
+
for (const target of environmentsOf(config, env)) {
|
|
238
|
+
const plan = planAppWorkflows(app, { project, env: target });
|
|
239
|
+
const stanza = stanzaFor(config, target);
|
|
240
|
+
const stanzaBefore = stringify(stanza);
|
|
241
|
+
|
|
242
|
+
replaceOwnWorkflows(stanza, plan);
|
|
243
|
+
setCrons(stanza, plan.crons);
|
|
244
|
+
|
|
245
|
+
// Never write a config wrangler will not load. A hand-edited entry that lost a field lands here too,
|
|
246
|
+
// which is the right place to hear about it — before the next deploy.
|
|
247
|
+
const problems = incompleteBindings(stanza);
|
|
248
|
+
if (problems.length > 0) {
|
|
249
|
+
throw new InternalError({
|
|
250
|
+
message: `wrangler.jsonc would not load with the ${target} bindings.`,
|
|
251
|
+
action: `Fix the ${target} bindings in ${workerDir}/wrangler.jsonc by hand, then run pithy worker sync again.`,
|
|
252
|
+
detail: problems.join("; "),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
runs.push({
|
|
257
|
+
env: target,
|
|
258
|
+
workflows: plan.workflows,
|
|
259
|
+
crons: plan.crons,
|
|
260
|
+
changed: stringify(stanza) !== stanzaBefore,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (stringify(config) !== before) await writeWranglerConfig(workerDir, config);
|
|
265
|
+
return runs;
|
|
266
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { DOMAIN_ENVIRONMENTS, domainFor, originFor, type WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
5
|
+
import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a Worker's `routes` and `vars.BASE_URL` from its `domains` declaration.
|
|
9
|
+
*
|
|
10
|
+
* One declaration in, two derived wrangler values out, per environment. Nothing is hand-maintained: the
|
|
11
|
+
* whole point of #89 Part 1 is that an adopter states the address once and everything downstream of it is
|
|
12
|
+
* computed, so a route and a `BASE_URL` can no longer disagree with each other or with the declaration.
|
|
13
|
+
*
|
|
14
|
+
* `BASE_URL` stays a wrangler var rather than moving into the declaration alone, because the **Worker
|
|
15
|
+
* reads it at runtime** — `@pithy-sh/email` builds tracking and unsubscribe links against it, and the
|
|
16
|
+
* email Workflow host has it stamped into its own config at provision time. What changed is that it is
|
|
17
|
+
* derived rather than hand-set.
|
|
18
|
+
*
|
|
19
|
+
* ## Per environment, always
|
|
20
|
+
*
|
|
21
|
+
* `env.<name>` stanzas **replace** the top level rather than merging with it, so a value written once at
|
|
22
|
+
* the top would be invisible to staging and prod. That is the same trap `scaffoldProject` handles with
|
|
23
|
+
* `replaceAll` for `PROJECT`/`WORKER`, and the reason nothing here writes to the top-level stanza: `dev`
|
|
24
|
+
* has no domain by design.
|
|
25
|
+
*
|
|
26
|
+
* ## In place, never replaced
|
|
27
|
+
*
|
|
28
|
+
* `comment-json` stores an adopter's comments as symbol-keyed properties on the very array or object they
|
|
29
|
+
* hang off, so replacing an array deletes their notes. Every mutation here reuses the existing container.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** One environment's derived values, for the caller to report. */
|
|
33
|
+
export interface AppliedDomain {
|
|
34
|
+
/** The environment written. */
|
|
35
|
+
env: string;
|
|
36
|
+
/** The hostname the route now points at. */
|
|
37
|
+
pattern: string;
|
|
38
|
+
/** The base URL written to `vars.BASE_URL`. */
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
/**
|
|
41
|
+
* Whether this environment's stanza actually changed.
|
|
42
|
+
*
|
|
43
|
+
* Carried so a command can say "already in sync" and mean it. The write was always idempotent in bytes;
|
|
44
|
+
* this makes the *report* idempotent too, which is the difference between a reconcile command an
|
|
45
|
+
* adopter runs freely and one that appears to do something every time they run it.
|
|
46
|
+
*/
|
|
47
|
+
changed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The wrangler shape this writes. Only the keys it owns. */
|
|
51
|
+
interface DomainStanza {
|
|
52
|
+
routes?: unknown[];
|
|
53
|
+
route?: unknown;
|
|
54
|
+
vars?: Record<string, unknown>;
|
|
55
|
+
workers_dev?: unknown;
|
|
56
|
+
}
|
|
57
|
+
interface DomainWrangler extends DomainStanza {
|
|
58
|
+
env?: Record<string, DomainStanza | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A route entry as this generator writes one — a custom domain attached to its zone. */
|
|
62
|
+
interface CustomDomainRoute {
|
|
63
|
+
pattern?: string;
|
|
64
|
+
custom_domain?: boolean;
|
|
65
|
+
zone_name?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Upsert the custom-domain route for one environment, in place.
|
|
70
|
+
*
|
|
71
|
+
* Matches on `custom_domain: true` rather than on the pattern, because the pattern is precisely what may
|
|
72
|
+
* have changed — matching on it would append a second entry for the same Worker every time an adopter
|
|
73
|
+
* moved their domain, and wrangler would then serve whichever it liked. Any other route entry the adopter
|
|
74
|
+
* wrote is left exactly where it is: this owns the custom domain, not the route list.
|
|
75
|
+
*/
|
|
76
|
+
function upsertRoute(stanza: DomainStanza, pattern: string, zone: string): void {
|
|
77
|
+
if (!Array.isArray(stanza.routes)) stanza.routes = [];
|
|
78
|
+
const routes = stanza.routes;
|
|
79
|
+
const existing = routes.find(
|
|
80
|
+
(entry): entry is CustomDomainRoute =>
|
|
81
|
+
typeof entry === "object" && entry !== null && (entry as CustomDomainRoute).custom_domain === true,
|
|
82
|
+
);
|
|
83
|
+
if (existing) {
|
|
84
|
+
existing.pattern = pattern;
|
|
85
|
+
existing.zone_name = zone;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
routes.push({ pattern, custom_domain: true, zone_name: zone });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Turn off `workers.dev` for an environment that now has a custom domain — unless the adopter has
|
|
93
|
+
* already said what they want.
|
|
94
|
+
*
|
|
95
|
+
* **A declared domain is the origin, and `workers.dev` is a second one nothing declared.** Wrangler's
|
|
96
|
+
* `workers_dev` defaults to `true` and declaring `routes` does not change it, so a Worker with a custom
|
|
97
|
+
* domain also answers on `<name>.<subdomain>.workers.dev` — and `preview_urls` defaults to whatever
|
|
98
|
+
* `workers_dev` is, so every deployed version is reachable there too. `vars.BASE_URL` beside it names
|
|
99
|
+
* only the custom domain, so on that second origin the OAuth callbacks and magic links point elsewhere
|
|
100
|
+
* and the CSRF same-origin gate refuses the very requests that establish who you are. Reachable, and
|
|
101
|
+
* broken in exactly that half. Anything bound to the hostname rather than the script — a WAF rule, an
|
|
102
|
+
* Access policy, a per-hostname rate limit — does not apply there at all.
|
|
103
|
+
*
|
|
104
|
+
* **Written only when the key is absent, unlike the route and `BASE_URL` beside it.** Those two are
|
|
105
|
+
* *derived* from the declaration and are overwritten every run, because a stale one contradicts it.
|
|
106
|
+
* This is not derived: the declaration makes `false` the right default and does not make it the only
|
|
107
|
+
* answer. A team that wants the `workers.dev` URL for staging until DNS is cut over writes
|
|
108
|
+
* `"workers_dev": true`, and that is a named origin rather than an unnamed one — which is the whole
|
|
109
|
+
* distinction `originDrift` is built on. Overwriting it would delete the sentence they wrote.
|
|
110
|
+
*/
|
|
111
|
+
function closeWorkersDev(stanza: DomainStanza): void {
|
|
112
|
+
if (typeof stanza.workers_dev === "boolean") return;
|
|
113
|
+
stanza.workers_dev = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Write the declaration into a Worker's `wrangler.jsonc`. Returns what it wrote, per environment.
|
|
118
|
+
*
|
|
119
|
+
* Idempotent: a second run changes nothing and writes nothing, and running it after an adopter moved their
|
|
120
|
+
* domain updates the one route entry rather than appending another. An environment with no declared domain
|
|
121
|
+
* is left completely alone — never cleared, because an adopter may have written a route by hand and this
|
|
122
|
+
* must not delete it just because they have not adopted the declaration for that environment.
|
|
123
|
+
*
|
|
124
|
+
* **This is the only thing that writes a route, and until #264 the only way to reach it was a prompt.**
|
|
125
|
+
* Its one caller was `writeDomains`, called only from an interactive `pithy init` or `pithy worker add` —
|
|
126
|
+
* and `askDomains` returns nothing at all when a session is not interactive. So a `--json` scaffold, a CI
|
|
127
|
+
* run, and every adopter who added `domains` to a `pithy.config.ts` by hand got a declaration with no route
|
|
128
|
+
* behind it, and nothing downstream ever noticed. `pithy worker sync` is the second caller, and the
|
|
129
|
+
* non-interactive one: the declaration goes in the config, the command writes what it implies.
|
|
130
|
+
*/
|
|
131
|
+
export async function applyDomains(workerDir: string, domains: WorkerDomains): Promise<AppliedDomain[]> {
|
|
132
|
+
const config = (await readWranglerConfig(workerDir)) as DomainWrangler;
|
|
133
|
+
const applied: AppliedDomain[] = [];
|
|
134
|
+
|
|
135
|
+
for (const env of DOMAIN_ENVIRONMENTS) {
|
|
136
|
+
const domain = domainFor(domains, env);
|
|
137
|
+
if (!domain) continue;
|
|
138
|
+
|
|
139
|
+
// `dev` is never in `DOMAIN_ENVIRONMENTS`, so this only ever reaches an `env.<name>` stanza — which is
|
|
140
|
+
// also the only place the values would be read from, since env stanzas replace the top level.
|
|
141
|
+
config.env ??= {};
|
|
142
|
+
// Structural, before anything is touched. `JSON.stringify` sees none of comment-json's symbol-keyed
|
|
143
|
+
// comment properties, which is exactly right here: a run that changes no value changed nothing, and
|
|
144
|
+
// an adopter's comments are not a diff.
|
|
145
|
+
const before = JSON.stringify(config.env[env] ?? null);
|
|
146
|
+
config.env[env] ??= {};
|
|
147
|
+
const stanza = config.env[env];
|
|
148
|
+
|
|
149
|
+
upsertRoute(stanza, domain.pattern, domain.zone);
|
|
150
|
+
stanza.vars ??= {};
|
|
151
|
+
// Through `originFor`, never `baseUrlFor` directly — the same call an adopter's `pithy.config.ts`
|
|
152
|
+
// makes to hand a capability its origin (#256). That is what makes "`vars.BASE_URL` and the
|
|
153
|
+
// capability configs cannot disagree" a property of the code rather than a thing to remember.
|
|
154
|
+
const baseUrl = originFor(env, domains);
|
|
155
|
+
stanza.vars.BASE_URL = baseUrl;
|
|
156
|
+
closeWorkersDev(stanza);
|
|
157
|
+
|
|
158
|
+
applied.push({ env, pattern: domain.pattern, baseUrl, changed: before !== JSON.stringify(stanza) });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Nothing changed, nothing written. The bytes were already identical, so the only thing skipping the
|
|
162
|
+
// write removes is an mtime bump on a file the adopter has open — and the one thing a reconcile command
|
|
163
|
+
// must never do is look like it edited something it did not.
|
|
164
|
+
if (applied.some((entry) => entry.changed)) await writeWranglerConfig(workerDir, config);
|
|
165
|
+
return applied;
|
|
166
|
+
}
|