@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,519 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
6
|
+
import { type ControlPlaneAuditAction, ControlPlaneAuditActions } from "@pithy-sh/core/src/controlPlane/audit/actions";
|
|
7
|
+
import { ControlPlaneConnection, type RegisteredKey } from "@pithy-sh/core/src/controlPlane/data/connection";
|
|
8
|
+
import { activeKeys, appendKey as appendRegisteredKey } from "@pithy-sh/core/src/controlPlane/data/keyLifecycle";
|
|
9
|
+
import {
|
|
10
|
+
CONTROL_PLANE_CONNECTIONS_TABLE,
|
|
11
|
+
type ControlPlaneDatabase,
|
|
12
|
+
controlPlaneDatabase,
|
|
13
|
+
} from "@pithy-sh/core/src/controlPlane/data/tables";
|
|
14
|
+
import {
|
|
15
|
+
ControlPlaneKeyConflictError,
|
|
16
|
+
ControlPlaneNotConnectedError,
|
|
17
|
+
} from "@pithy-sh/core/src/controlPlane/error/errors";
|
|
18
|
+
import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
19
|
+
import type { CliAuditEmit, CliAuditEvent } from "../audit/cliAudit";
|
|
20
|
+
import { type CloudflareAccountSelection, cloudflareCredentials } from "../cloudflare/config";
|
|
21
|
+
import { discoverWorkers } from "../project/workers";
|
|
22
|
+
import { openSeedDriver, type SeedDriver } from "../seed/drivers";
|
|
23
|
+
import { createCliLogger } from "../terminal/logger";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The adopter's side of the control-plane registration: the `pithy_controlplane_connections` row, read
|
|
27
|
+
* and written from the CLI.
|
|
28
|
+
*
|
|
29
|
+
* **Connection is project-wide, per environment — never per Worker** (docs/CONTROL-PLANE.md §15).
|
|
30
|
+
* Workers share a resource by declaring the same binding name, so one user record lives in one D1 that
|
|
31
|
+
* several Workers touch; a per-Worker credential would produce a dashboard where a user is visible in
|
|
32
|
+
* one pane and absent from another. So the row is keyed on `environment`, and a Worker is only how the
|
|
33
|
+
* CLI *finds* the database.
|
|
34
|
+
*
|
|
35
|
+
* Two rules this module does not get to bend. Every read is `ControlPlaneConnection.parse` and every
|
|
36
|
+
* write is `.encode` — the row holds dates as ms-epoch and scopes and keys as JSON, and the codecs are
|
|
37
|
+
* the only thing that knows that. And a key is appended through core's `appendKey`, never by touching
|
|
38
|
+
* the array here: the ordering that keeps a failed rotation from locking the adopter out is a property
|
|
39
|
+
* of that function, and a second implementation of it is a second thing to get wrong.
|
|
40
|
+
*
|
|
41
|
+
* ## The invariant (#287)
|
|
42
|
+
*
|
|
43
|
+
* **The CLI adds a key to a connection only when no live key exists to sign for one through the seam.**
|
|
44
|
+
*
|
|
45
|
+
* `POST {basePath}/keys` is the adopter's own boundary for registering a key: it checks the connection's
|
|
46
|
+
* `keys:rotate` grant, it records the registration in their audit trail, and it is signed with the key
|
|
47
|
+
* being replaced. A CLI writing the same column direct to D1 is a second authority over it — not a
|
|
48
|
+
* second implementation, since both paths call core's `appendKey`, but a second *place the decision is
|
|
49
|
+
* made*, which means the safety property holds for callers who remember and for nobody else.
|
|
50
|
+
*
|
|
51
|
+
* So the direct write is narrowed to the one case the seam cannot serve: a connection with nothing live
|
|
52
|
+
* to sign with. That is first connect — no key exists, and the Worker may not even be deployed, so
|
|
53
|
+
* requiring a running Worker to register the key that lets anyone talk to it would be a chicken-and-egg
|
|
54
|
+
* with no exit. It is also the recovery case, a connection whose every key was revoked, and the same
|
|
55
|
+
* sentence covers both because it is the same fact: nothing can sign.
|
|
56
|
+
*
|
|
57
|
+
* Stated as a property rather than a list of blessed callers, and enforced here rather than upstream,
|
|
58
|
+
* because a rule kept at the call sites regrows the moment someone adds one. {@link connectionRegistry}
|
|
59
|
+
* is the CLI's only door to that column, and `registry.test.ts` asserts both halves — that nothing else
|
|
60
|
+
* in the CLI opens the table, and that neither write gets through while a key is live.
|
|
61
|
+
*
|
|
62
|
+
* **Revocation is deliberately outside it.** {@link ConnectionRegistry.revokeKey} changes the key set of
|
|
63
|
+
* a connection with live keys, on purpose: it *removes* trust, and revocation that needed the Worker's
|
|
64
|
+
* cooperation would not be revocation (docs/CONTROL-PLANE.md §7). The invariant is about granting.
|
|
65
|
+
*
|
|
66
|
+
* ## The second invariant (#294)
|
|
67
|
+
*
|
|
68
|
+
* **A write to this table records itself in the adopter's own audit trail.**
|
|
69
|
+
*
|
|
70
|
+
* `ControlPlaneAuditActions` declared `connectionRegistered`, `connectionUpdated` and
|
|
71
|
+
* `connectionRemoved` from the day the seam shipped, and nothing emitted any of them. Not an oversight
|
|
72
|
+
* in a handler: those are the writes the CLI performs by opening the adopter's D1 directly, so no
|
|
73
|
+
* request reaches their Worker and no route is in a position to record one. An adopter could read a
|
|
74
|
+
* *key* rotation in their trail but not the connection being created or destroyed — the larger event
|
|
75
|
+
* was the invisible one.
|
|
76
|
+
*
|
|
77
|
+
* The recording is here, in the same functions as the writes, rather than at the call sites, for the
|
|
78
|
+
* same reason the key invariant is: a rule kept at the call sites regrows the moment someone adds one.
|
|
79
|
+
* Together with the tripwire asserting nothing else in the CLI opens this table, "every CLI write to an
|
|
80
|
+
* adopter's connection row is recorded" holds by construction rather than by discipline.
|
|
81
|
+
*
|
|
82
|
+
* Three things this settles, each of which is a way to get it wrong.
|
|
83
|
+
*
|
|
84
|
+
* **Where.** The event goes to the emitter this registry was built with, which
|
|
85
|
+
* {@link openConnectionRegistry} builds over the *same* `DB` handle the row is written through. Not a
|
|
86
|
+
* separately resolved one: on `dev` the registry's handle is a local Miniflare store and a resolved
|
|
87
|
+
* database id names the real remote database, so a second lookup would record the change in a database
|
|
88
|
+
* that never saw it.
|
|
89
|
+
*
|
|
90
|
+
* **When.** The row is written first and the event follows. A write that throws records nothing, and a
|
|
91
|
+
* record that fails cannot unwind the write and does not try — the emitter is non-fatal by contract, so
|
|
92
|
+
* a connect never fails because the trail was unavailable. The record is evidence of the change, never
|
|
93
|
+
* part of making it. The reverse order would let a `connect` announce a registration that did not land.
|
|
94
|
+
*
|
|
95
|
+
* **Whether.** An adopter need not compose `audit`. The emitter is then a no-op and every write here is
|
|
96
|
+
* unchanged, which is why this takes an always-callable function rather than an optional one: a call
|
|
97
|
+
* site that cannot tell whether auditing is on cannot forget to check.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One connection-lifecycle event, in the fields the seam's own routes already use.
|
|
102
|
+
*
|
|
103
|
+
* `resourceType`, `resourceId` and the two metadata keys match `keyRegistered` and `keyExpired` exactly,
|
|
104
|
+
* so an adopter filtering their trail to one connection gets the CLI-side and Worker-side halves of its
|
|
105
|
+
* history together rather than having to know that two writers named the same thing two ways.
|
|
106
|
+
*
|
|
107
|
+
* `severity` is `warning` for the same reason those are: a management client gaining, moving, or losing
|
|
108
|
+
* reach into an environment is the notable kind of event, not the routine kind.
|
|
109
|
+
*
|
|
110
|
+
* The actor is deliberately absent — {@link CliAuditEvent} has no `actorType`, because the CLI does not
|
|
111
|
+
* get to claim one. A route records `control-plane`, meaning the management client called in and proved
|
|
112
|
+
* it; the CLI is the adopter's own operator at a terminal, named from their Cloudflare token where there
|
|
113
|
+
* is one and unattributed where there is not. Letting this file set it would let a CLI-side write
|
|
114
|
+
* present itself as a verified caller.
|
|
115
|
+
*/
|
|
116
|
+
function lifecycleEvent(
|
|
117
|
+
action: ControlPlaneAuditAction,
|
|
118
|
+
connection: ControlPlaneConnection,
|
|
119
|
+
metadata: Record<string, unknown>,
|
|
120
|
+
): CliAuditEvent {
|
|
121
|
+
return {
|
|
122
|
+
action,
|
|
123
|
+
outcome: "success",
|
|
124
|
+
severity: "warning",
|
|
125
|
+
resourceType: "controlplane_connection",
|
|
126
|
+
resourceId: connection.id,
|
|
127
|
+
environment: connection.environment,
|
|
128
|
+
metadata: { connectionId: connection.id, connectionEnvironment: connection.environment, ...metadata },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Key **ids**, never key material — the trail is queryable and long-lived. */
|
|
133
|
+
function keyIds(connection: ControlPlaneConnection): string[] {
|
|
134
|
+
return connection.keys.map((registered) => registered.keyId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What a re-point moved, field by field, so the trail answers "what changed" without a diff of two rows.
|
|
139
|
+
*
|
|
140
|
+
* Scopes are compared as a set rendered in order: a grant is what it contains, and reporting a
|
|
141
|
+
* reordering as a change would bury the one that matters.
|
|
142
|
+
*/
|
|
143
|
+
function addressAndGrantChanges(
|
|
144
|
+
stored: ControlPlaneConnection,
|
|
145
|
+
connection: ControlPlaneConnection,
|
|
146
|
+
): Record<string, unknown> {
|
|
147
|
+
const before = [...stored.scopes].sort();
|
|
148
|
+
const after = [...connection.scopes].sort();
|
|
149
|
+
return {
|
|
150
|
+
...(stored.workerUrl === connection.workerUrl
|
|
151
|
+
? {}
|
|
152
|
+
: { workerUrl: { from: stored.workerUrl, to: connection.workerUrl } }),
|
|
153
|
+
...(stored.basePath === connection.basePath
|
|
154
|
+
? {}
|
|
155
|
+
: { basePath: { from: stored.basePath, to: connection.basePath } }),
|
|
156
|
+
...(before.join(" ") === after.join(" ") ? {} : { scopes: { from: before, to: after } }),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Refuse a key write while something live could have signed for it at the seam.
|
|
162
|
+
*
|
|
163
|
+
* The gate #287's invariant is stated as. It reads the connection as stored — never as offered — so a
|
|
164
|
+
* caller cannot talk its way past by describing the keys it wishes were there.
|
|
165
|
+
*/
|
|
166
|
+
function refuseWhileSomethingCanSign(connection: ControlPlaneConnection, now: Date, detail: string): void {
|
|
167
|
+
const live = activeKeys(connection.keys, now);
|
|
168
|
+
if (live.length === 0) return;
|
|
169
|
+
throw new ControlPlaneKeyConflictError({
|
|
170
|
+
message: "That connection already has a live key.",
|
|
171
|
+
action: `Register the successor through the worker: POST ${connection.workerUrl}${connection.basePath}/keys, signed with ${live[0]?.keyId}. That is what pithy dashboard rotate does.`,
|
|
172
|
+
detail,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read, write, and revoke one environment's registration. */
|
|
177
|
+
export interface ConnectionRegistry {
|
|
178
|
+
/** The environment's connection, or null when nothing is registered — the default, denying state. */
|
|
179
|
+
read(): Promise<ControlPlaneConnection | null>;
|
|
180
|
+
/** Write the environment's connection, replacing whatever was registered for it. */
|
|
181
|
+
save(connection: ControlPlaneConnection): Promise<void>;
|
|
182
|
+
/**
|
|
183
|
+
* Append a public key through core's lifecycle rules, and return the updated connection.
|
|
184
|
+
*
|
|
185
|
+
* **Refused while the connection has a live key** — see the invariant above. This is the bootstrap
|
|
186
|
+
* path only: nothing live means nothing can sign a registration at the seam, so the CLI is the only
|
|
187
|
+
* thing that can put a key there.
|
|
188
|
+
*/
|
|
189
|
+
appendKey(key: RegisteredKey, now: Date): Promise<ControlPlaneConnection>;
|
|
190
|
+
/**
|
|
191
|
+
* Revoke one registered key, immediately. Returns the updated connection, or null when no key answers
|
|
192
|
+
* to that id.
|
|
193
|
+
*
|
|
194
|
+
* This is the adopter's side of revocation at key granularity: pulling one leaked key without tearing
|
|
195
|
+
* down the connection and re-running `connect`. It is a write to their own D1, needs no route, and
|
|
196
|
+
* needs nothing from the management client — which is what "immediate and unilateral" has to mean.
|
|
197
|
+
*/
|
|
198
|
+
revokeKey(keyId: string, now: Date): Promise<ControlPlaneConnection | null>;
|
|
199
|
+
/** Delete the registration. `false` when there was nothing to delete — re-running is not an error. */
|
|
200
|
+
remove(): Promise<boolean>;
|
|
201
|
+
/** Release whatever the registry opened (the local Miniflare instance; nothing remotely). */
|
|
202
|
+
dispose(): Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The registry over an already-open database. Pure enough to test against a real D1 with no project on
|
|
207
|
+
* disk, which is the whole reason it is separate from {@link openConnectionRegistry}.
|
|
208
|
+
*/
|
|
209
|
+
export function connectionRegistry(
|
|
210
|
+
db: ControlPlaneDatabase,
|
|
211
|
+
env: string,
|
|
212
|
+
dispose: () => Promise<void> = async () => {},
|
|
213
|
+
/**
|
|
214
|
+
* Where a write records itself (#294). Defaults to dropping the event, which is what a project not
|
|
215
|
+
* composing `audit` gets — and what a test constructing a bare registry gets, so the recording is
|
|
216
|
+
* opt-in for a caller that has a trail to write to and invisible to every caller that has not.
|
|
217
|
+
*/
|
|
218
|
+
emit: CliAuditEmit = async () => {},
|
|
219
|
+
): ConnectionRegistry {
|
|
220
|
+
/**
|
|
221
|
+
* Record an event, and let no failure of it reach the write it was recording.
|
|
222
|
+
*
|
|
223
|
+
* {@link createCliAudit}'s emitter is non-fatal by contract and logs its own drops, so this is belt
|
|
224
|
+
* and braces — but the writes below are the adopter's security boundary and do not get to assume
|
|
225
|
+
* their collaborators behave. The same argument core's `safeEmit` makes, for the same reason: a
|
|
226
|
+
* `disconnect` that failed because the trail was unreachable would leave a credential live on the
|
|
227
|
+
* strength of an audit problem, which is precisely backwards. It is not silent, though; a broken
|
|
228
|
+
* collaborator is worse hidden than seen.
|
|
229
|
+
*/
|
|
230
|
+
async function record(event: CliAuditEvent): Promise<void> {
|
|
231
|
+
try {
|
|
232
|
+
await emit(event);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
createCliLogger().child("dashboard").warn("connection lifecycle event dropped", { action: event.action, error });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function read(): Promise<ControlPlaneConnection | null> {
|
|
239
|
+
const row = await db
|
|
240
|
+
.selectFrom(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
241
|
+
.selectAll()
|
|
242
|
+
.where("environment", "=", env)
|
|
243
|
+
.executeTakeFirst();
|
|
244
|
+
return row ? ControlPlaneConnection.parse(row) : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
read,
|
|
249
|
+
|
|
250
|
+
async save(connection) {
|
|
251
|
+
// `save` creates or replaces a whole connection, and replacing one is how an adopter starts over:
|
|
252
|
+
// a new id, a new keypair, nothing carried forward. Nothing live can sign for a key on a
|
|
253
|
+
// connection that does not exist yet, so a replacement is first connect and passes.
|
|
254
|
+
//
|
|
255
|
+
// Keeping the same connection and rewriting its keys is the other thing entirely — that is a
|
|
256
|
+
// rotation wearing a create's clothes, and it goes through the seam like every other one.
|
|
257
|
+
const stored = await read();
|
|
258
|
+
if (stored && stored.id === connection.id) {
|
|
259
|
+
const before = ControlPlaneConnection.shape.keys.encode([...stored.keys]);
|
|
260
|
+
const after = ControlPlaneConnection.shape.keys.encode([...connection.keys]);
|
|
261
|
+
if (before !== after) {
|
|
262
|
+
refuseWhileSomethingCanSign(stored, connection.updatedAt, `save rewrote the keys of connection ${stored.id}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// One connection per environment. SQLite carries no unique index on `environment` — the column is
|
|
267
|
+
// indexed for reads, not constrained — so the invariant is enforced here, by replacing.
|
|
268
|
+
await db.deleteFrom(CONTROL_PLANE_CONNECTIONS_TABLE).where("environment", "=", env).execute();
|
|
269
|
+
await db.insertInto(CONTROL_PLANE_CONNECTIONS_TABLE).values(ControlPlaneConnection.encode(connection)).execute();
|
|
270
|
+
|
|
271
|
+
// The row landed, so the event can be written. A new id — or none stored at all — is a management
|
|
272
|
+
// client that did not have reach into this environment a moment ago and now does; the same id is
|
|
273
|
+
// the same client re-pointed. Those are different facts to an adopter reading their trail, and the
|
|
274
|
+
// id is the only thing that tells them apart, so it is what decides the code.
|
|
275
|
+
if (stored !== null && stored.id === connection.id) {
|
|
276
|
+
const changes = addressAndGrantChanges(stored, connection);
|
|
277
|
+
await record(
|
|
278
|
+
lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, connection, {
|
|
279
|
+
changed: Object.keys(changes),
|
|
280
|
+
...changes,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await record(
|
|
286
|
+
lifecycleEvent(ControlPlaneAuditActions.connectionRegistered, connection, {
|
|
287
|
+
issuer: connection.issuer,
|
|
288
|
+
workerUrl: connection.workerUrl,
|
|
289
|
+
basePath: connection.basePath,
|
|
290
|
+
scopes: [...connection.scopes],
|
|
291
|
+
registeredKeyIds: keyIds(connection),
|
|
292
|
+
// Starting over replaces a connection rather than editing one, and the id that stopped
|
|
293
|
+
// working is what an adopter needs to recognize their own older rows by.
|
|
294
|
+
...(stored === null ? {} : { replacedConnectionId: stored.id }),
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async appendKey(key, now) {
|
|
300
|
+
const connection = await read();
|
|
301
|
+
if (!connection) {
|
|
302
|
+
throw new ControlPlaneNotConnectedError({
|
|
303
|
+
detail: `no connection registered for environment ${env}`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
refuseWhileSomethingCanSign(connection, now, `appendKey ${key.keyId} on connection ${connection.id}`);
|
|
307
|
+
// Core owns the rule. It refuses a duplicate id and a key that is already retired, and it never
|
|
308
|
+
// moves an existing key's window — which is what leaves the old credential working until the new
|
|
309
|
+
// one has been proven.
|
|
310
|
+
const keys = appendRegisteredKey(connection.keys, key, now);
|
|
311
|
+
const updated: ControlPlaneConnection = { ...connection, keys, updatedAt: now };
|
|
312
|
+
await db
|
|
313
|
+
.updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
314
|
+
.set(ControlPlaneConnection.encode(updated))
|
|
315
|
+
.where("id", "=", connection.id)
|
|
316
|
+
.execute();
|
|
317
|
+
// The connection existed and now trusts a key it did not, so this is a change to it rather than a
|
|
318
|
+
// registration of one. The seam's own `keyRegistered` is not the code for it: that one means the
|
|
319
|
+
// Worker accepted a signed registration through the route, and nothing here went near the Worker.
|
|
320
|
+
await record(
|
|
321
|
+
lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, updated, {
|
|
322
|
+
changed: ["keys"],
|
|
323
|
+
registeredKeyId: key.keyId,
|
|
324
|
+
keyIds: keyIds(updated),
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
return updated;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
async revokeKey(keyId, now) {
|
|
331
|
+
const connection = await read();
|
|
332
|
+
if (!connection) {
|
|
333
|
+
throw new ControlPlaneNotConnectedError({
|
|
334
|
+
detail: `no connection registered for environment ${env}`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (!connection.keys.some((key) => key.keyId === keyId)) return null;
|
|
338
|
+
|
|
339
|
+
// `revokedAt`, not `validUntil`. Expiry is the orderly end of a rotation and core refuses it while
|
|
340
|
+
// it would leave no live key; revocation is the disorderly one, and it must not be refusable on
|
|
341
|
+
// those grounds — a leaked key has to come out even if it is the only one. That leaves the
|
|
342
|
+
// connection denying every call, which is the correct state for a credential you no longer trust,
|
|
343
|
+
// and `pithy dashboard connect` is the way back.
|
|
344
|
+
const keys = connection.keys.map((key) => (key.keyId === keyId ? { ...key, revokedAt: now } : key));
|
|
345
|
+
const updated: ControlPlaneConnection = { ...connection, keys, updatedAt: now };
|
|
346
|
+
await db
|
|
347
|
+
.updateTable(CONTROL_PLANE_CONNECTIONS_TABLE)
|
|
348
|
+
.set(ControlPlaneConnection.encode(updated))
|
|
349
|
+
.where("id", "=", connection.id)
|
|
350
|
+
.execute();
|
|
351
|
+
// Recorded after the revocation, never before it, and it cannot fail one. Revocation is immediate
|
|
352
|
+
// and unilateral (§7); a leaked key that stayed live because the trail was unreachable would be the
|
|
353
|
+
// audit tail wagging the security dog. The count of what is still live is on the row because
|
|
354
|
+
// revoking the last one leaves the connection denying everything, and that is the fact a reader
|
|
355
|
+
// asking "when did this stop working" is looking for.
|
|
356
|
+
await record(
|
|
357
|
+
lifecycleEvent(ControlPlaneAuditActions.connectionUpdated, updated, {
|
|
358
|
+
changed: ["keys"],
|
|
359
|
+
revokedKeyId: keyId,
|
|
360
|
+
liveKeyIdsAfter: activeKeys(updated.keys, now).map((key) => key.keyId),
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
return updated;
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
async remove() {
|
|
367
|
+
const existing = await read();
|
|
368
|
+
if (!existing) return false;
|
|
369
|
+
await db.deleteFrom(CONTROL_PLANE_CONNECTIONS_TABLE).where("environment", "=", env).execute();
|
|
370
|
+
// The widest-blast-radius write there is: every credential for this environment stopped working at
|
|
371
|
+
// once. Recorded from the connection as it was, because after the delete there is nothing left to
|
|
372
|
+
// describe it — which is exactly why an adopter needs the row.
|
|
373
|
+
await record(
|
|
374
|
+
lifecycleEvent(ControlPlaneAuditActions.connectionRemoved, existing, {
|
|
375
|
+
issuer: existing.issuer,
|
|
376
|
+
workerUrl: existing.workerUrl,
|
|
377
|
+
basePath: existing.basePath,
|
|
378
|
+
scopes: [...existing.scopes],
|
|
379
|
+
removedKeyIds: keyIds(existing),
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
return true;
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
dispose,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Test seam: open the backend driver for a Worker and environment.
|
|
391
|
+
*
|
|
392
|
+
* **It carries the account (#234).** The seam is shaped by what {@link openSeedDriver} needs, and that
|
|
393
|
+
* function's `account` was optional until then — so this type could not name one, so `openConnectionRegistry`
|
|
394
|
+
* could not pass one even once it wanted to. A registry lookup on a non-`dev` environment opens a real D1
|
|
395
|
+
* over REST, and it was opening it against whichever account `<config>/cloudflare.json` held.
|
|
396
|
+
*/
|
|
397
|
+
export type OpenDriver = (options: {
|
|
398
|
+
workerDir: string;
|
|
399
|
+
persistRoot: string;
|
|
400
|
+
env: string;
|
|
401
|
+
account: CloudflareAccountSelection | null;
|
|
402
|
+
}) => Promise<SeedDriver>;
|
|
403
|
+
|
|
404
|
+
/** Options for {@link openConnectionRegistry}. */
|
|
405
|
+
export interface OpenConnectionRegistryOptions {
|
|
406
|
+
/** The project root — the parent of `apps/`, and the owner of the local `.wrangler/state` stores. */
|
|
407
|
+
projectDir: string;
|
|
408
|
+
/** The environment whose registration is being read or written. */
|
|
409
|
+
env: string;
|
|
410
|
+
/**
|
|
411
|
+
* The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`, or
|
|
412
|
+
* `null` when it names none. Required (#234): every non-`dev` environment resolves the app database
|
|
413
|
+
* over REST, and the credentials that reads it with belong to exactly one account.
|
|
414
|
+
*/
|
|
415
|
+
account: CloudflareAccountSelection | null;
|
|
416
|
+
/** Narrow the database lookup to one Worker (`--worker`). Optional; the first with a `DB` wins. */
|
|
417
|
+
worker?: string;
|
|
418
|
+
/** Driver seam (default: {@link openSeedDriver}), so a test needs no Miniflare of the CLI's making. */
|
|
419
|
+
openDriver?: OpenDriver;
|
|
420
|
+
/**
|
|
421
|
+
* Build the recorder a write records itself through (#294), over **the database this registry resolved**
|
|
422
|
+
* — which is why it is a factory taking that handle rather than an emitter passed in ready-made.
|
|
423
|
+
*
|
|
424
|
+
* The `DB` a connection row lives in is decided here, by walking the project's Workers, and on `dev` it
|
|
425
|
+
* is a local Miniflare store that no database id names. An emitter built anywhere else would be writing
|
|
426
|
+
* the record of a change into a database the change never reached. Passing the handle out is what makes
|
|
427
|
+
* "the event goes where the row went" a property of the wiring instead of a thing two call sites have to
|
|
428
|
+
* agree about.
|
|
429
|
+
*
|
|
430
|
+
* Omitted, nothing is recorded — the shape a test and a project without `audit` both get.
|
|
431
|
+
*/
|
|
432
|
+
openAudit?: (database: D1Database) => Promise<CliAuditEmit>;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Settle the account before a single Worker is considered (#236).
|
|
437
|
+
*
|
|
438
|
+
* **A per-item refusal collector will swallow a whole-run refusal**, because the whole-run failure arrives
|
|
439
|
+
* wearing a per-item costume. The loop below gathers "why each Worker was skipped" so one unresolvable
|
|
440
|
+
* Worker does not stop the rest — a summary that is exactly the wrong shape for a fact settled before the
|
|
441
|
+
* loop began. An account mismatch went in as a per-Worker reason and came out as "No worker resolves the
|
|
442
|
+
* DB binding for this environment", pointing an adopter at a `wrangler.jsonc` that was fine. #199 was the
|
|
443
|
+
* same shape: a config that would not import, reported as "this Worker declares no secrets".
|
|
444
|
+
*
|
|
445
|
+
* So the two whole-project facts are asked here, once, and thrown rather than collected — the way
|
|
446
|
+
* `migrate`, `deploy`, `env`, `seed` and `upgrade` already refuse. {@link cloudflareCredentials} owns both
|
|
447
|
+
* sentences, so the mismatch reads exactly as `pithy doctor` reads it out.
|
|
448
|
+
*
|
|
449
|
+
* **`dev` is exempt because it consults no account.** The local driver resolves D1 from the project root's
|
|
450
|
+
* Miniflare stores and never builds a client, so no credential decides anything; refusing there would
|
|
451
|
+
* block work that never leaves the machine over a disagreement it cannot reach. Every other environment
|
|
452
|
+
* resolves the database over REST, which means credentials, which means exactly one account.
|
|
453
|
+
*/
|
|
454
|
+
function requireAccountSettled(env: string, account: CloudflareAccountSelection | null): void {
|
|
455
|
+
if (env === "dev") return;
|
|
456
|
+
cloudflareCredentials({ account });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Open the registry for a project and environment.
|
|
461
|
+
*
|
|
462
|
+
* The database comes from a Worker's `wrangler.jsonc`, because that is where bindings are declared —
|
|
463
|
+
* but **the state it persists to is the project root's**, exactly as `pithy dev`, `migrate`, and `seed`
|
|
464
|
+
* do. A per-Worker persist directory would give two Workers that both declare `DB` separate copies of
|
|
465
|
+
* one database, and the registration would be visible to whichever Worker happened to write it.
|
|
466
|
+
*
|
|
467
|
+
* Which Worker? `--worker` names one; otherwise the first discovered Worker declaring a `DB` binding.
|
|
468
|
+
* Deliberately not an ambiguity error, for the same reason the audit lookup is not: Workers share a
|
|
469
|
+
* resource by declaring the same binding name, so every Worker with a `DB` points at the one app
|
|
470
|
+
* database.
|
|
471
|
+
*/
|
|
472
|
+
export async function openConnectionRegistry(options: OpenConnectionRegistryOptions): Promise<ConnectionRegistry> {
|
|
473
|
+
const open = options.openDriver ?? openSeedDriver;
|
|
474
|
+
requireAccountSettled(options.env, options.account);
|
|
475
|
+
const workers = await discoverWorkers(options.projectDir);
|
|
476
|
+
const candidates =
|
|
477
|
+
options.worker === undefined
|
|
478
|
+
? workers
|
|
479
|
+
: workers.filter(
|
|
480
|
+
(worker) =>
|
|
481
|
+
worker.name === options.worker || worker.dir === join(options.projectDir, "apps", options.worker ?? ""),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const refusals: string[] = [];
|
|
485
|
+
for (const candidate of candidates) {
|
|
486
|
+
const driver = await open({
|
|
487
|
+
workerDir: candidate.dir,
|
|
488
|
+
persistRoot: options.projectDir,
|
|
489
|
+
env: options.env,
|
|
490
|
+
account: options.account,
|
|
491
|
+
});
|
|
492
|
+
try {
|
|
493
|
+
const d1 = driver.d1("DB");
|
|
494
|
+
// Resolved before the registry is built, so a registry never exists without the recorder that
|
|
495
|
+
// belongs to its own database. The factory is not allowed to fail the command it is auditing: a
|
|
496
|
+
// project whose audit wiring cannot be built still connects, silently, and records nothing.
|
|
497
|
+
const record = await options.openAudit?.(d1).catch(() => undefined);
|
|
498
|
+
return connectionRegistry(controlPlaneDatabase(d1), options.env, () => driver.dispose(), record);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
// This Worker cannot answer "where does the app database live" — usually because it declares no
|
|
501
|
+
// `DB` at all (a UI-only Worker in `apps/` is normal), sometimes because this env's stanza has no
|
|
502
|
+
// id. Both are collected rather than thrown: another Worker may still resolve it, and if none
|
|
503
|
+
// does, the reasons are what makes the final error debuggable.
|
|
504
|
+
refusals.push(`${candidate.name}: ${messageOf(error)}`);
|
|
505
|
+
await driver.dispose();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Every reason is per-Worker by now — the whole-project ones were refused before the loop — so the
|
|
510
|
+
// summary is honest, and the reasons themselves belong on the `action` line rather than only in
|
|
511
|
+
// `detail`. `detail` is stripped at the display boundary and always will be; an operator staring at
|
|
512
|
+
// "add the DB binding" on a project that has one needs to be told which Worker said what.
|
|
513
|
+
const why = refusals.length === 0 ? "No workers under apps/." : `Skipped: ${refusals.join("; ")}.`;
|
|
514
|
+
throw new ValidationError({
|
|
515
|
+
message: "No worker resolves the DB binding for this environment.",
|
|
516
|
+
action: `${why} Add the DB d1_databases binding to a worker's wrangler.jsonc, or pass --worker to name one.`,
|
|
517
|
+
detail: `env ${options.env}; ${refusals.length === 0 ? "no workers under apps/" : refusals.join("; ")}`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { isControlPlaneCapability } from "@pithy-sh/core/src/controlPlane/capability";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { loadWorkerDomains } from "../project/config";
|
|
7
|
+
import { type AddressStanza, describeAddressSource, resolveWorkerAddress } from "../project/workerAddress";
|
|
8
|
+
import { type ResolvedWorker, resolveSingleWorker } from "../project/workerScope";
|
|
9
|
+
import { readWranglerConfig } from "../project/wrangler";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What `pithy dashboard connect` is registering: which Worker, at what address, with the seam mounted
|
|
13
|
+
* where.
|
|
14
|
+
*
|
|
15
|
+
* ## Why the Worker has to be named
|
|
16
|
+
*
|
|
17
|
+
* The administrative surface is composed on **one Worker per project**, and a connection targets it. In
|
|
18
|
+
* a multi-Worker project, "connect this project" is ambiguous, and guessing means registering an address
|
|
19
|
+
* that answers nothing. Every other per-Worker command already refuses that ambiguity through
|
|
20
|
+
* `resolveSingleWorker` — this reuses it rather than inventing a second rule.
|
|
21
|
+
*
|
|
22
|
+
* Sibling Workers are not separately addressable, which is right: the data being administered is shared
|
|
23
|
+
* through binding names, not owned per Worker.
|
|
24
|
+
*
|
|
25
|
+
* ## Why the address is resolved rather than demanded
|
|
26
|
+
*
|
|
27
|
+
* `connect` used to require `--worker-url` with no fallback at all, and softened it only with an
|
|
28
|
+
* interactive free-text prompt — so the agent and CI path, which is the one that matters for
|
|
29
|
+
* automation, simply threw. The project already knows where its Workers answer; asking again invites a
|
|
30
|
+
* value that disagrees with the routes beside it.
|
|
31
|
+
*
|
|
32
|
+
* ## Why the base path comes from the composed config
|
|
33
|
+
*
|
|
34
|
+
* It is the one address a client cannot discover, because it *is* the manifest's own address. Reading it
|
|
35
|
+
* off the composed capability means an adopter who moved the mount has their real one registered, rather
|
|
36
|
+
* than the default being assumed on their behalf — which registers cleanly, passes the ping at that same
|
|
37
|
+
* assumed path, and then 404s on every call.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The Worker a connection targets, and how to reach its seam. */
|
|
41
|
+
export interface ConnectTarget {
|
|
42
|
+
/** The resolved Worker. */
|
|
43
|
+
worker: ResolvedWorker;
|
|
44
|
+
/** Its base URL for this environment. */
|
|
45
|
+
workerUrl: string;
|
|
46
|
+
/** Where its control-plane seam is mounted, from the composed capability's resolved config. */
|
|
47
|
+
basePath: string;
|
|
48
|
+
/** A one-line account of where the address came from, for the confirmation line. */
|
|
49
|
+
source: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The seam's mount point on this Worker, or null when it composes no seam. */
|
|
53
|
+
function composedBasePath(worker: ResolvedWorker): string | null {
|
|
54
|
+
const seam = worker.capabilities.find(isControlPlaneCapability);
|
|
55
|
+
return seam ? seam.controlPlaneConfig.basePath : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the Worker, address, and base path a connect targets.
|
|
60
|
+
*
|
|
61
|
+
* `workerUrl` still overrides everything — an adopter fronting their Worker with a proxy has an address
|
|
62
|
+
* no config knows. It remains the escape hatch, not the requirement.
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveConnectTarget(options: {
|
|
65
|
+
projectDir: string;
|
|
66
|
+
environment: string;
|
|
67
|
+
/** `--worker`, when given. Absent in a single-Worker project; required when there are several. */
|
|
68
|
+
worker?: string | undefined;
|
|
69
|
+
/** `--worker-url`, when given. Overrides the resolver. */
|
|
70
|
+
workerUrl?: string | undefined;
|
|
71
|
+
}): Promise<ConnectTarget> {
|
|
72
|
+
const worker = await resolveSingleWorker({
|
|
73
|
+
projectDir: options.projectDir,
|
|
74
|
+
...(options.worker === undefined ? {} : { worker: options.worker }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const basePath = composedBasePath(worker);
|
|
78
|
+
if (basePath === null) {
|
|
79
|
+
throw new ValidationError({
|
|
80
|
+
message: `${worker.name} does not compose the control-plane seam, so there is nothing to connect.`,
|
|
81
|
+
action: "Add `controlplane()` to that Worker's pithy.config.ts, deploy, then run connect again.",
|
|
82
|
+
detail: `no controlplane capability in ${worker.dir}'s composed set`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.workerUrl) {
|
|
87
|
+
return { worker, workerUrl: options.workerUrl, basePath, source: "from --worker-url" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let stanza: AddressStanza | undefined;
|
|
91
|
+
try {
|
|
92
|
+
stanza = ((await readWranglerConfig(worker.dir)) as { env?: Record<string, AddressStanza | undefined> }).env?.[
|
|
93
|
+
options.environment
|
|
94
|
+
];
|
|
95
|
+
} catch {
|
|
96
|
+
stanza = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const address = resolveWorkerAddress({
|
|
100
|
+
environment: options.environment,
|
|
101
|
+
domains: loadWorkerDomains(worker.config),
|
|
102
|
+
stanza,
|
|
103
|
+
});
|
|
104
|
+
if (!address) {
|
|
105
|
+
throw new ValidationError({
|
|
106
|
+
message: `${worker.name} has no ${options.environment} address to register.`,
|
|
107
|
+
action:
|
|
108
|
+
'Declare it in the Worker\'s pithy.config.ts — `domains: { prod: { pattern: "api.example.com", zone: "example.com" } }` — or pass --worker-url.',
|
|
109
|
+
detail: `no domains declaration, route, or vars.BASE_URL resolved for env.${options.environment} in ${worker.dir}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { worker, workerUrl: address.url, basePath, source: describeAddressSource(address.source) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The line shown before registering, so an operator sees the address and where it came from. */
|
|
117
|
+
export function describeConnectTarget(target: ConnectTarget): string {
|
|
118
|
+
return `${target.worker.name} → ${target.workerUrl}${target.basePath} (${target.source})`;
|
|
119
|
+
}
|