@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,524 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **Nothing non-deterministic may be evaluated in a Workflow driver body.**
|
|
6
|
+
*
|
|
7
|
+
* A Cloudflare Workflow does not resume inside the step it died in. It re-executes the driver from the top
|
|
8
|
+
* and serves every completed step from the journal. So a value the body *computes* answers differently on
|
|
9
|
+
* every attempt, while a value the journal *holds* does not — and every field the pages behind the
|
|
10
|
+
* interruption were already written under has to come from the second kind.
|
|
11
|
+
*
|
|
12
|
+
* The kit has now shipped this defect four times, in four capabilities, one field at a time: payments minted
|
|
13
|
+
* its run id in the body (#328), then read its clock in the body in the very fix for it (#331), and testers,
|
|
14
|
+
* email and secrets each held a body-read clock of their own. Patching the reported field and leaving its
|
|
15
|
+
* sibling is the failure mode, so this is a statement about *where* a value may be produced rather than a
|
|
16
|
+
* list of the ways to produce one.
|
|
17
|
+
*
|
|
18
|
+
* ## The rule, and why it is not a list of function names
|
|
19
|
+
*
|
|
20
|
+
* > **A driver body may not evaluate a nullary call or a nullary construction.**
|
|
21
|
+
*
|
|
22
|
+
* A call that takes no arguments cannot compute its result from its input, because it has none. Whatever it
|
|
23
|
+
* returns therefore came from outside the program — a clock, an entropy source, a counter, a queue. That is
|
|
24
|
+
* the whole population of things a replay must not re-read, and it is closed under the next author's
|
|
25
|
+
* favorite spelling of it: `Date.now()`, `new Date()`, `crypto.randomUUID()`, `performance.now()`,
|
|
26
|
+
* `deps.now()` and the sixth thing nobody has written yet are all nullary, necessarily, because a source
|
|
27
|
+
* takes no input. A deny-list of names would have caught the first five and not the sixth — which is
|
|
28
|
+
* pithy-sh/pithy#326 finding 4 in miniature, and precisely what this refuses to be.
|
|
29
|
+
*
|
|
30
|
+
* Two structural exceptions, both of which are the rule rather than holes in it:
|
|
31
|
+
*
|
|
32
|
+
* - **A function-like node is not evaluated where it is written.** `now: () => new Date()` handed into a
|
|
33
|
+
* step is the *correct* pattern — the thunk is defined in the body and called inside the journal — so
|
|
34
|
+
* the walk stops at every function boundary. That is also what excludes a `step.do` callback, which
|
|
35
|
+
* needs no special case: it is an arrow like any other.
|
|
36
|
+
* - **A module-local function called from the body is part of the body.** `await buildSendDeps(this.env)`
|
|
37
|
+
* is one argument long and perfectly innocent-looking, and email's clock was inside it. So the walk
|
|
38
|
+
* follows a call to a function declared in the same module and judges its body by the same rule.
|
|
39
|
+
*
|
|
40
|
+
* ## What this does not see
|
|
41
|
+
*
|
|
42
|
+
* A thunk defined and then *called* in the same driver body would be missed: the call site is an identifier
|
|
43
|
+
* this walker does not resolve to its arrow. Nothing in the tree does that, and the population assertions in
|
|
44
|
+
* `workflowDeterminism.test.ts` are what would surface a new driver that did.
|
|
45
|
+
*
|
|
46
|
+
* The population is discovered, never declared: a driver is the `run` of a class extending
|
|
47
|
+
* `WorkflowEntrypoint`, or any function taking a parameter typed as a step runner — and a step runner is
|
|
48
|
+
* itself discovered, as any interface in the tree whose one member is `do(name, callback)`. A capability
|
|
49
|
+
* that adds a Workflow tomorrow is analyzed tomorrow, with nothing to remember.
|
|
50
|
+
*
|
|
51
|
+
* ## A second rule, off the same walk (#426)
|
|
52
|
+
*
|
|
53
|
+
* > **Every module in this kit that extends `WorkflowEntrypoint` has a default export.**
|
|
54
|
+
*
|
|
55
|
+
* A worker whose entry exports only classes is not an ES module as far as the build is concerned. wrangler
|
|
56
|
+
* warns, falls back to service-worker format, and then refuses the entry outright — `Unexpected external
|
|
57
|
+
* import of "cloudflare:workers" and "cloudflare:workflows"` — so the host does not build at all. `pithy dev`
|
|
58
|
+
* carries on with the rest of the set, which is how three of these shipped: `support`, `media` and `vector`
|
|
59
|
+
* each had no cron, so nothing prompted anyone to write the `export default { async scheduled(…) }` that
|
|
60
|
+
* happened to make the other four ES modules. The format was a side effect of a feature four hosts wanted
|
|
61
|
+
* and three did not.
|
|
62
|
+
*
|
|
63
|
+
* So the property belongs to the *class*, not to whoever remembers it, and it is answered here rather than
|
|
64
|
+
* in a list: this walk already knows every module in the tree that extends `WorkflowEntrypoint`, and whether
|
|
65
|
+
* that module has a default export is one more question about the same file. See {@link WorkflowHostModule}
|
|
66
|
+
* and `workflowModuleFormat.test.ts`, which is what holds the kit to it.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** A source file the analysis reads. The same shape `sourceFiles` yields, so the tree walk feeds it directly. */
|
|
70
|
+
export interface DriverSource {
|
|
71
|
+
/** Path to the file, used in findings. */
|
|
72
|
+
readonly path: string;
|
|
73
|
+
/** Its text. */
|
|
74
|
+
readonly text: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One Workflow driver body the analysis found. */
|
|
78
|
+
export interface WorkflowDriver {
|
|
79
|
+
/** The file it lives in. */
|
|
80
|
+
readonly file: string;
|
|
81
|
+
/** `ClassName.run` for a Workflow class, or the function's name for a delegate that takes a step runner. */
|
|
82
|
+
readonly name: string;
|
|
83
|
+
/** Whether it is the Workflow class itself or a function the class hands its step runner to. */
|
|
84
|
+
readonly kind: "entrypoint" | "delegate";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One evaluation of a source in a driver body — the thing this gate exists to refuse. */
|
|
88
|
+
export interface DriverFinding extends WorkflowDriver {
|
|
89
|
+
/** 1-indexed line of the offending expression. */
|
|
90
|
+
readonly line: number;
|
|
91
|
+
/** The expression as written, e.g. `deps.now()` or `new Date()`. */
|
|
92
|
+
readonly expression: string;
|
|
93
|
+
/** Where it was found: the driver body itself, or a module-local function the body calls. */
|
|
94
|
+
readonly via: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One module that hosts at least one Workflow, and whether the build will read it as an ES module.
|
|
99
|
+
*
|
|
100
|
+
* The unit is the **file**, not the class: `main` in a `wrangler.jsonc` names a module, and the default
|
|
101
|
+
* export is a property of that module however many Workflow classes it happens to hold.
|
|
102
|
+
*/
|
|
103
|
+
export interface WorkflowHostModule {
|
|
104
|
+
/** The file, repo-relative as the caller supplied it. */
|
|
105
|
+
readonly file: string;
|
|
106
|
+
/** Every `WorkflowEntrypoint` subclass it declares, sorted. Never empty — that is what puts it here. */
|
|
107
|
+
readonly classes: string[];
|
|
108
|
+
/** Whether the module declares a default export, in any of the three spellings ESTree has for one. */
|
|
109
|
+
readonly defaultExport: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What one analysis pass found. */
|
|
113
|
+
export interface DriverAnalysis {
|
|
114
|
+
/** Every `WorkflowEntrypoint` subclass in the tree, as `path#ClassName`, sorted. */
|
|
115
|
+
readonly entrypoints: string[];
|
|
116
|
+
/** Every module holding one or more of those classes, sorted by path. See {@link WorkflowHostModule}. */
|
|
117
|
+
readonly hosts: WorkflowHostModule[];
|
|
118
|
+
/** Every driver body analyzed. */
|
|
119
|
+
readonly drivers: WorkflowDriver[];
|
|
120
|
+
/** Every `WorkflowSpec.className` declared anywhere in the tree, sorted. */
|
|
121
|
+
readonly declaredClassNames: string[];
|
|
122
|
+
/** Every violation of the rule. */
|
|
123
|
+
readonly findings: DriverFinding[];
|
|
124
|
+
/** Files that were parsed. A gate over an empty population is not a gate. */
|
|
125
|
+
readonly parsed: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An AST node, structurally. The walk needs a discriminant, a position and children; it needs nothing else.
|
|
130
|
+
*
|
|
131
|
+
* **ESTree.** The names below — `MethodDefinition`, `Property`, `Literal` — are the standard ones, not any
|
|
132
|
+
* one parser's dialect, which is what lets the parser behind `ParseModule` be replaced without touching this.
|
|
133
|
+
*/
|
|
134
|
+
export interface Node {
|
|
135
|
+
readonly type: string;
|
|
136
|
+
/** Byte offset of the node in the source text. Turned into a line by `lineIndex`. */
|
|
137
|
+
readonly start?: number;
|
|
138
|
+
readonly [key: string]: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The parser, injected.
|
|
143
|
+
*
|
|
144
|
+
* A seam rather than an import, for one reason: the parser this gate uses is a dev-only dependency, and this
|
|
145
|
+
* module ships. Taking it as a parameter keeps a published `@pithy-sh/cli` free of a module that imports
|
|
146
|
+
* something an installer never gets — and lets the fixture cases in the gate's own suite run through exactly
|
|
147
|
+
* the code path the tree scan does.
|
|
148
|
+
*
|
|
149
|
+
* It must yield an ESTree program. Everything this module knows about a tree is written in ESTree's names.
|
|
150
|
+
*/
|
|
151
|
+
export type ParseModule = (text: string) => Node;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The node kinds that defer evaluation of what is inside them.
|
|
155
|
+
*
|
|
156
|
+
* This is a fact about the language rather than a policy: a function body runs where the function is called,
|
|
157
|
+
* so nothing written inside one is evaluated at the point it appears. It is what makes `() => new Date()`
|
|
158
|
+
* handed to a step legal and `new Date()` beside it not.
|
|
159
|
+
*
|
|
160
|
+
* Three entries, and that is the whole of it in ESTree: a method is a `MethodDefinition` or a `Property`
|
|
161
|
+
* *wrapping* a `FunctionExpression`, so the wrapper is walked — its computed key is evaluated where it is
|
|
162
|
+
* written — and the function inside it is not.
|
|
163
|
+
*/
|
|
164
|
+
const DEFERRED = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
165
|
+
|
|
166
|
+
/** The superclass every Cloudflare Workflow extends. The platform's name for the seam, not ours. */
|
|
167
|
+
const WORKFLOW_BASE = "WorkflowEntrypoint";
|
|
168
|
+
|
|
169
|
+
function isNode(value: unknown): value is Node {
|
|
170
|
+
return typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Every child node of `node`, in source order. Property-driven, so it needs no per-kind knowledge. */
|
|
174
|
+
function children(node: Node): Node[] {
|
|
175
|
+
const found: Node[] = [];
|
|
176
|
+
for (const key of Object.keys(node)) {
|
|
177
|
+
const value = node[key];
|
|
178
|
+
if (isNode(value)) found.push(value);
|
|
179
|
+
else if (Array.isArray(value)) for (const item of value) if (isNode(item)) found.push(item);
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Offsets to 1-indexed lines.
|
|
186
|
+
*
|
|
187
|
+
* An ESTree node carries a byte offset, not a line, so the line a finding prints is derived here from the
|
|
188
|
+
* same text the parser read. Built once per file and searched, rather than counted per finding.
|
|
189
|
+
*/
|
|
190
|
+
function lineIndex(text: string): (offset: number) => number {
|
|
191
|
+
const starts = [0];
|
|
192
|
+
for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) starts.push(index + 1);
|
|
193
|
+
return (offset) => {
|
|
194
|
+
let low = 0;
|
|
195
|
+
let high = starts.length - 1;
|
|
196
|
+
while (low < high) {
|
|
197
|
+
const middle = (low + high + 1) >> 1;
|
|
198
|
+
if ((starts[middle] as number) <= offset) low = middle;
|
|
199
|
+
else high = middle - 1;
|
|
200
|
+
}
|
|
201
|
+
return low + 1;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function nameOf(node: unknown): string | undefined {
|
|
206
|
+
return isNode(node) && node.type === "Identifier" && typeof node.name === "string" ? node.name : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `deps.now` → `deps.now`; `crypto.randomUUID` → `crypto.randomUUID`; anything computed → undefined. */
|
|
210
|
+
function memberPath(node: Node): string | undefined {
|
|
211
|
+
if (node.type === "Identifier") return nameOf(node);
|
|
212
|
+
if (node.type !== "MemberExpression" || node.computed === true) return undefined;
|
|
213
|
+
const object = isNode(node.object) ? memberPath(node.object) : undefined;
|
|
214
|
+
const property = nameOf(node.property);
|
|
215
|
+
if (node.type === "MemberExpression" && isNode(node.object) && node.object.type === "ThisExpression") {
|
|
216
|
+
return property === undefined ? undefined : `this.${property}`;
|
|
217
|
+
}
|
|
218
|
+
return object !== undefined && property !== undefined ? `${object}.${property}` : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Is this interface a step runner?
|
|
223
|
+
*
|
|
224
|
+
* Structurally: one member, called `do`, taking a name and a callback. That is the whole of the seam every
|
|
225
|
+
* package declares for itself — `ReconcileStep`, `ReprocessStep`, `StepRunner` — and it is how a fourth one
|
|
226
|
+
* added tomorrow is recognized without being written down here.
|
|
227
|
+
*/
|
|
228
|
+
function isStepRunnerInterface(node: Node): boolean {
|
|
229
|
+
if (node.type !== "TSInterfaceDeclaration" || !isNode(node.body)) return false;
|
|
230
|
+
const members = Array.isArray(node.body.body) ? node.body.body.filter(isNode) : [];
|
|
231
|
+
if (members.length !== 1) return false;
|
|
232
|
+
const member = members[0] as Node;
|
|
233
|
+
if (member.type !== "TSMethodSignature") return false;
|
|
234
|
+
return nameOf(member.key) === "do" && Array.isArray(member.params);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The type name a parameter is annotated with, if it is a plain reference. */
|
|
238
|
+
function parameterTypeName(param: Node): string | undefined {
|
|
239
|
+
const annotation = param.typeAnnotation;
|
|
240
|
+
if (!isNode(annotation) || !isNode(annotation.typeAnnotation)) return undefined;
|
|
241
|
+
const reference = annotation.typeAnnotation;
|
|
242
|
+
if (reference.type !== "TSTypeReference" || !isNode(reference.typeName)) return undefined;
|
|
243
|
+
return nameOf(reference.typeName);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Does this module declare a default export?
|
|
248
|
+
*
|
|
249
|
+
* Top-level only, because that is the only place one can be, and structural rather than textual: a comment
|
|
250
|
+
* or a string containing `export default` is not one, and `export { entry as default }` is.
|
|
251
|
+
*
|
|
252
|
+
* All three ESTree spellings count, and a re-export counts too — `export { default } from "./entry"` puts a
|
|
253
|
+
* default binding on this module, which is the whole of what the build asks.
|
|
254
|
+
*/
|
|
255
|
+
function hasDefaultExport(program: Node): boolean {
|
|
256
|
+
const body = Array.isArray(program.body) ? program.body.filter(isNode) : [];
|
|
257
|
+
for (const statement of body) {
|
|
258
|
+
// A type is not a default export, whatever it is spelled like — Jim, 2026-08-21.
|
|
259
|
+
//
|
|
260
|
+
// `export type { HostEntry as default }` and `export default interface HostEntry {}` both put the
|
|
261
|
+
// word `default` in the syntax tree and neither survives to runtime: `verbatimModuleSyntax` erases
|
|
262
|
+
// them, so the emitted module has no default binding and wrangler infers Service Worker format
|
|
263
|
+
// exactly as before. An adversarial pass planted both against a copy of the pre-fix support host
|
|
264
|
+
// and this function answered `true` while the real build still failed with `Unexpected external
|
|
265
|
+
// import of "cloudflare:workers"`.
|
|
266
|
+
//
|
|
267
|
+
// `exportKind` is `"type"` on those and `"value"` on everything real, which is the fact this rule
|
|
268
|
+
// is actually about: the build asks whether the *emitted* module has a default binding.
|
|
269
|
+
if (statement.exportKind === "type") continue;
|
|
270
|
+
if (statement.type === "ExportDefaultDeclaration") {
|
|
271
|
+
// And the declaration itself, because `export default interface X {}` is a value-kind statement
|
|
272
|
+
// whose declaration is a type. There is nothing to emit for either.
|
|
273
|
+
const declared = isNode(statement.declaration) ? statement.declaration.type : "";
|
|
274
|
+
if (declared === "TSInterfaceDeclaration" || declared === "TSTypeAliasDeclaration") continue;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") {
|
|
278
|
+
const specifiers = Array.isArray(statement.specifiers) ? statement.specifiers.filter(isNode) : [];
|
|
279
|
+
const exported = statement.type === "ExportAllDeclaration" ? [statement] : specifiers;
|
|
280
|
+
for (const entry of exported) {
|
|
281
|
+
// Per-specifier too: `export { a, type b as default }` is a value-kind statement carrying one
|
|
282
|
+
// type specifier, so the statement-level check above does not see it.
|
|
283
|
+
if (entry.exportKind === "type") continue;
|
|
284
|
+
if (nameOf(entry.exported) === "default") return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Collect every function declaration in a module, by name, so a call from a driver body can be followed. */
|
|
292
|
+
function moduleFunctions(program: Node): Map<string, Node> {
|
|
293
|
+
const found = new Map<string, Node>();
|
|
294
|
+
const visit = (node: Node): void => {
|
|
295
|
+
if (node.type === "FunctionDeclaration") {
|
|
296
|
+
const name = nameOf(node.id);
|
|
297
|
+
if (name !== undefined) found.set(name, node);
|
|
298
|
+
}
|
|
299
|
+
for (const child of children(node)) visit(child);
|
|
300
|
+
};
|
|
301
|
+
visit(program);
|
|
302
|
+
return found;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Every name a binding pattern introduces — a plain identifier, or one buried in destructuring. */
|
|
306
|
+
function boundNames(pattern: unknown, into: Set<string>): void {
|
|
307
|
+
if (!isNode(pattern)) return;
|
|
308
|
+
const name = nameOf(pattern);
|
|
309
|
+
if (name !== undefined) {
|
|
310
|
+
into.add(name);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
for (const child of children(pattern)) boundNames(child, into);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The bindings a scope holds that carry a value it already has, rather than a way of getting one.
|
|
318
|
+
*
|
|
319
|
+
* This is the line between `now.getTime()` and `deps.now()`, and it is the only thing in this file that has to
|
|
320
|
+
* be careful. `now` is a `const` the body computed from a journaled step; calling a method on it reads a
|
|
321
|
+
* value already in hand and answers the same on every replay. `deps` is a *parameter* — the injected effect
|
|
322
|
+
* surface — and `Date` is a free identifier the module never declared. Both of those reach outside.
|
|
323
|
+
*
|
|
324
|
+
* So: a local declared in this scope is a value; a parameter, an import, and a global are not.
|
|
325
|
+
*
|
|
326
|
+
* **Aliasing is not a way round it.** A local whose initializer is just a name or a property of one — the
|
|
327
|
+
* `const clock = deps.clock` shape — is a second name for the seam rather than a value, so it does not count
|
|
328
|
+
* as local. A local initialized from an expression that *produces* something (a call, a construction, an
|
|
329
|
+
* `await step.do`) does.
|
|
330
|
+
*/
|
|
331
|
+
function localValues(scope: Node): Set<string> {
|
|
332
|
+
const locals = new Set<string>();
|
|
333
|
+
const visit = (node: Node): void => {
|
|
334
|
+
if (node !== scope && DEFERRED.has(node.type)) return;
|
|
335
|
+
if (node.type === "VariableDeclarator") {
|
|
336
|
+
const initializer = node.init;
|
|
337
|
+
const aliasesASeam =
|
|
338
|
+
isNode(initializer) &&
|
|
339
|
+
(initializer.type === "Identifier" ||
|
|
340
|
+
(initializer.type === "MemberExpression" && memberPath(initializer) !== undefined));
|
|
341
|
+
if (!aliasesASeam) boundNames(node.id, locals);
|
|
342
|
+
}
|
|
343
|
+
for (const child of children(node)) visit(child);
|
|
344
|
+
};
|
|
345
|
+
visit(scope);
|
|
346
|
+
return locals;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Walk one scope, refusing every nullary evaluation of something the scope does not already hold, and
|
|
351
|
+
* following calls into this module's own functions.
|
|
352
|
+
*
|
|
353
|
+
* `seen` guards recursion through a helper that calls itself; `via` names where a finding actually sits, so
|
|
354
|
+
* a clock two frames down from the body is reported at the function that holds it rather than at the call.
|
|
355
|
+
*/
|
|
356
|
+
function walkScope(
|
|
357
|
+
scope: Node,
|
|
358
|
+
context: {
|
|
359
|
+
driver: WorkflowDriver;
|
|
360
|
+
functions: Map<string, Node>;
|
|
361
|
+
via: string;
|
|
362
|
+
seen: Set<string>;
|
|
363
|
+
lineAt: (offset: number) => number;
|
|
364
|
+
},
|
|
365
|
+
findings: DriverFinding[],
|
|
366
|
+
): void {
|
|
367
|
+
const locals = localValues(scope);
|
|
368
|
+
const visit = (node: Node): void => {
|
|
369
|
+
if (node !== scope && DEFERRED.has(node.type)) return;
|
|
370
|
+
|
|
371
|
+
// `a?.b()` is a `CallExpression` inside a `ChainExpression` in ESTree, so the two kinds below are the
|
|
372
|
+
// whole population of evaluations. The chain wrapper needs no case: it is walked like any other node.
|
|
373
|
+
if (node.type === "CallExpression" || node.type === "NewExpression") {
|
|
374
|
+
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
|
375
|
+
const callee = isNode(node.callee) ? node.callee : undefined;
|
|
376
|
+
const path = callee ? memberPath(callee) : undefined;
|
|
377
|
+
const root = path?.split(".")[0];
|
|
378
|
+
|
|
379
|
+
// A call into this module's own code is part of the body, whatever its arity: the value it produces is
|
|
380
|
+
// produced here. Followed once, and judged by the same rule.
|
|
381
|
+
if (path !== undefined && context.functions.has(path) && !context.seen.has(path)) {
|
|
382
|
+
context.seen.add(path);
|
|
383
|
+
const target = context.functions.get(path) as Node;
|
|
384
|
+
walkScope(target, { ...context, via: path }, findings);
|
|
385
|
+
} else if (args.length === 0 && (root === undefined || !locals.has(root))) {
|
|
386
|
+
// Nullary, on something this scope does not already hold. It takes no input and its receiver is not a
|
|
387
|
+
// value the body computed, so whatever it answers came from outside the program.
|
|
388
|
+
const written = path === undefined ? node.type : path;
|
|
389
|
+
findings.push({
|
|
390
|
+
...context.driver,
|
|
391
|
+
line: node.start === undefined ? 0 : context.lineAt(node.start),
|
|
392
|
+
expression: node.type === "NewExpression" ? `new ${written}()` : `${written}()`,
|
|
393
|
+
via: context.via,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const child of children(node)) visit(child);
|
|
399
|
+
};
|
|
400
|
+
visit(scope);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Analyze a set of source files for Workflow driver bodies and the sources they evaluate.
|
|
405
|
+
*
|
|
406
|
+
* Everything is derived from the files: the Workflow classes, the modules that hold them, the step-runner
|
|
407
|
+
* interfaces, the delegates that take one, and the class names the `WorkflowSpec` maps declare. Nothing about
|
|
408
|
+
* the shipped population is written down here, which is the point — see the module doc.
|
|
409
|
+
*/
|
|
410
|
+
export function analyzeDrivers(sources: readonly DriverSource[], parseModule: ParseModule): DriverAnalysis {
|
|
411
|
+
const programs: { source: DriverSource; program: Node }[] = [];
|
|
412
|
+
for (const source of sources) programs.push({ source, program: parseModule(source.text) });
|
|
413
|
+
|
|
414
|
+
// Pass one: every step-runner interface in the tree, by name.
|
|
415
|
+
const stepRunners = new Set<string>();
|
|
416
|
+
for (const { program } of programs) {
|
|
417
|
+
const visit = (node: Node): void => {
|
|
418
|
+
if (isStepRunnerInterface(node)) {
|
|
419
|
+
const name = nameOf(node.id);
|
|
420
|
+
if (name !== undefined) stepRunners.add(name);
|
|
421
|
+
}
|
|
422
|
+
for (const child of children(node)) visit(child);
|
|
423
|
+
};
|
|
424
|
+
visit(program);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const entrypoints: string[] = [];
|
|
428
|
+
const hosts: WorkflowHostModule[] = [];
|
|
429
|
+
const declaredClassNames = new Set<string>();
|
|
430
|
+
const drivers: WorkflowDriver[] = [];
|
|
431
|
+
const findings: DriverFinding[] = [];
|
|
432
|
+
|
|
433
|
+
for (const { source, program } of programs) {
|
|
434
|
+
const functions = moduleFunctions(program);
|
|
435
|
+
const bodies: { driver: WorkflowDriver; scope: Node }[] = [];
|
|
436
|
+
// The classes this one file declares, collected as the walk finds them — the module-format rule asks
|
|
437
|
+
// about the file, and `entrypoints` is flat across the tree.
|
|
438
|
+
const hosted: string[] = [];
|
|
439
|
+
|
|
440
|
+
const visit = (node: Node): void => {
|
|
441
|
+
// A Workflow class: its `run` is a driver body, and the second parameter is the platform's step runner.
|
|
442
|
+
if (
|
|
443
|
+
(node.type === "ClassDeclaration" || node.type === "ClassExpression") &&
|
|
444
|
+
isNode(node.superClass) &&
|
|
445
|
+
nameOf(node.superClass) === WORKFLOW_BASE
|
|
446
|
+
) {
|
|
447
|
+
const className = nameOf(node.id) ?? "<anonymous>";
|
|
448
|
+
entrypoints.push(`${source.path}#${className}`);
|
|
449
|
+
hosted.push(className);
|
|
450
|
+
const members = isNode(node.body) && Array.isArray(node.body.body) ? node.body.body.filter(isNode) : [];
|
|
451
|
+
for (const member of members) {
|
|
452
|
+
if (member.type !== "MethodDefinition" || nameOf(member.key) !== "run") continue;
|
|
453
|
+
// The scope is the `FunctionExpression`, never the `MethodDefinition` that holds it. A method is a
|
|
454
|
+
// wrapper in ESTree, and a wrapper's first child is a deferred node — so walking the wrapper walks
|
|
455
|
+
// exactly nothing, silently, and reports every driver in the kit clean.
|
|
456
|
+
const body = member.value;
|
|
457
|
+
if (!isNode(body)) continue;
|
|
458
|
+
bodies.push({
|
|
459
|
+
driver: { file: source.path, name: `${className}.run`, kind: "entrypoint" },
|
|
460
|
+
scope: body,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A delegate: any function handed a step runner. It re-executes on resume exactly as the class does.
|
|
466
|
+
if (node.type === "FunctionDeclaration" && Array.isArray(node.params)) {
|
|
467
|
+
const params = node.params.filter(isNode);
|
|
468
|
+
if (
|
|
469
|
+
params.some((param) => {
|
|
470
|
+
const type = parameterTypeName(param);
|
|
471
|
+
return type !== undefined && stepRunners.has(type);
|
|
472
|
+
})
|
|
473
|
+
) {
|
|
474
|
+
const name = nameOf(node.id);
|
|
475
|
+
if (name !== undefined) {
|
|
476
|
+
bodies.push({ driver: { file: source.path, name, kind: "delegate" }, scope: node });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// A `WorkflowSpec`'s `className` — the second, independent enumeration of the shipped population.
|
|
482
|
+
//
|
|
483
|
+
// Recognized by the spec's own shape rather than by the key alone: `className` is also what a
|
|
484
|
+
// `durable_object` binding names its class with, so a bare key search collects `MatchmakingPresence`
|
|
485
|
+
// and asserts that a Durable Object is a Workflow. A `WorkflowSpec` is the object that carries a
|
|
486
|
+
// `binding`, a `params` schema and a `className` together.
|
|
487
|
+
if (node.type === "ObjectExpression" && Array.isArray(node.properties)) {
|
|
488
|
+
const properties = node.properties.filter(isNode);
|
|
489
|
+
const keys = new Set(properties.map((property) => nameOf(property.key)));
|
|
490
|
+
if (keys.has("binding") && keys.has("params") && keys.has("className")) {
|
|
491
|
+
const declared = properties.find((property) => nameOf(property.key) === "className");
|
|
492
|
+
const value = declared !== undefined && isNode(declared.value) ? declared.value : undefined;
|
|
493
|
+
if (value?.type === "Literal" && typeof value.value === "string") declaredClassNames.add(value.value);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const child of children(node)) visit(child);
|
|
498
|
+
};
|
|
499
|
+
visit(program);
|
|
500
|
+
|
|
501
|
+
// A file that declares no Workflow class is not a host, whatever else it exports. The default-export
|
|
502
|
+
// question is only asked of files the answer means something for.
|
|
503
|
+
if (hosted.length > 0) {
|
|
504
|
+
hosts.push({ file: source.path, classes: hosted.sort(), defaultExport: hasDefaultExport(program) });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (bodies.length > 0) {
|
|
508
|
+
const lineAt = lineIndex(source.text);
|
|
509
|
+
for (const { driver, scope } of bodies) {
|
|
510
|
+
drivers.push(driver);
|
|
511
|
+
walkScope(scope, { driver, functions, via: driver.name, seen: new Set([driver.name]), lineAt }, findings);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
entrypoints: entrypoints.sort(),
|
|
518
|
+
hosts: hosts.sort((a, b) => a.file.localeCompare(b.file)),
|
|
519
|
+
drivers: drivers.sort((a, b) => `${a.file}#${a.name}`.localeCompare(`${b.file}#${b.name}`)),
|
|
520
|
+
declaredClassNames: [...declaredClassNames].sort(),
|
|
521
|
+
findings,
|
|
522
|
+
parsed: programs.length,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import {
|
|
6
|
+
type CloudflareAccountConfirmation,
|
|
7
|
+
describeUnconfirmedCloudflareAccount,
|
|
8
|
+
UNCONFIRMED_CLOUDFLARE_ACCOUNT_ACTION,
|
|
9
|
+
} from "./config";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an account listing answered about one named resource (#378).
|
|
13
|
+
*
|
|
14
|
+
* **Three states, because the wire has two and one of them is two facts.** Cloudflare answers "the
|
|
15
|
+
* account has no such thing" and "you asked an account that is not yours" with the same empty array, and
|
|
16
|
+
* every call site that stored it in a `T | null` lost the difference at the assignment. `absent` is a
|
|
17
|
+
* fact — an account this run can vouch for was asked, and it does not hold the thing. `unconfirmed` is
|
|
18
|
+
* not a fact at all, and a caller that reads it as one deletes nothing while exiting 0, or creates a
|
|
19
|
+
* real resource in somebody else's account.
|
|
20
|
+
*
|
|
21
|
+
* It is a discriminated union rather than a nullable value **and rather than a convention**, so a call
|
|
22
|
+
* site that only handles two of the three does not compile. `storeId.ts` proved the shape with `null`
|
|
23
|
+
* versus `[]`; this is that shape with a name, for the sites where the third state is a refusal.
|
|
24
|
+
*/
|
|
25
|
+
export type AccountAnswer<T> =
|
|
26
|
+
| {
|
|
27
|
+
/** The account holds it. */
|
|
28
|
+
readonly state: "found";
|
|
29
|
+
/** What the listing returned. */
|
|
30
|
+
readonly value: T;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
/** The account was confirmed, was asked, and does not hold it. Idempotent teardown may stop here. */
|
|
34
|
+
readonly state: "absent";
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
/** Nothing vouches for the account that answered, so its answer settles nothing. */
|
|
38
|
+
readonly state: "unconfirmed";
|
|
39
|
+
/** The account that would have answered. Named, because the operator is about to be shown it. */
|
|
40
|
+
readonly accountId: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An account, and what vouches for it — the pair every destructive and creative site now carries.
|
|
45
|
+
*
|
|
46
|
+
* **One field rather than two**, because they are one fact and a site that has the id without the
|
|
47
|
+
* standing is the bug: `accountId` alone is what six deprovisioners already held while deleting nothing.
|
|
48
|
+
* `cloudflareAccountConfirmation` supplies the second half from the same resolution the first came from.
|
|
49
|
+
*/
|
|
50
|
+
export interface ConfirmedAccount {
|
|
51
|
+
/** The account every call is addressed to. Named in the refusal, so the operator sees which one answered. */
|
|
52
|
+
readonly accountId: string;
|
|
53
|
+
/** What vouches for it — `ambient` is the one value that refuses. */
|
|
54
|
+
readonly confirmation: CloudflareAccountConfirmation;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One lookup against one account, with everything a refusal needs to name. */
|
|
58
|
+
export interface ConfirmedAccountLookup<T> extends ConfirmedAccount {
|
|
59
|
+
/** What is being looked for, as a noun phrase: `the acme-prod-secrets Worker`. Named in the refusal's detail. */
|
|
60
|
+
readonly what: string;
|
|
61
|
+
/**
|
|
62
|
+
* The listing.
|
|
63
|
+
*
|
|
64
|
+
* **Not called at all when the account is unconfirmed.** A round trip whose answer cannot be believed
|
|
65
|
+
* either way is wasted, and — for the creative sites — it is one call closer to acting on it.
|
|
66
|
+
*/
|
|
67
|
+
readonly find: () => Promise<T | null>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ask an account for one named thing, and say which of the three things came back.
|
|
72
|
+
*
|
|
73
|
+
* For the caller that renders rather than refuses. Everything destructive or creative wants
|
|
74
|
+
* {@link findOnConfirmedAccount}, which is this plus the refusal.
|
|
75
|
+
*/
|
|
76
|
+
export async function answerOnConfirmedAccount<T>(lookup: ConfirmedAccountLookup<T>): Promise<AccountAnswer<T>> {
|
|
77
|
+
if (lookup.confirmation === "ambient") return { state: "unconfirmed", accountId: lookup.accountId };
|
|
78
|
+
const found = await lookup.find();
|
|
79
|
+
return found === null || found === undefined ? { state: "absent" } : { state: "found", value: found };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The thing, or its **confirmed** absence. Refuses rather than reading a miss on an unconfirmed account
|
|
84
|
+
* as an absence.
|
|
85
|
+
*
|
|
86
|
+
* This is the one-line form for the guards that were the whole of #378: the six deprovisioners'
|
|
87
|
+
* `if (await getWorker(name))`, and the find-or-create sites that mint a resource when the answer is
|
|
88
|
+
* `null`. Both read a miss as permission to proceed, and neither could tell which account had missed.
|
|
89
|
+
*/
|
|
90
|
+
export async function findOnConfirmedAccount<T>(lookup: ConfirmedAccountLookup<T>): Promise<T | null> {
|
|
91
|
+
const answer = await answerOnConfirmedAccount(lookup);
|
|
92
|
+
if (answer.state === "unconfirmed") throw unconfirmedAccount(answer.accountId, lookup.what);
|
|
93
|
+
return answer.state === "found" ? answer.value : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The refusal an unconfirmed account earns, in the sentence {@link describeUnconfirmedCloudflareAccount}
|
|
98
|
+
* spells once.
|
|
99
|
+
*
|
|
100
|
+
* `ConflictError`, matching `cloudflareEnv`'s refusal for the pinned case: two accounts are in play and
|
|
101
|
+
* nothing in the toolchain compares them. The thing being looked for goes in `detail` — it is throw-site
|
|
102
|
+
* context, and `detail` is stripped at the display boundary, which is where it belongs.
|
|
103
|
+
*/
|
|
104
|
+
export function unconfirmedAccount(accountId: string, what: string): ConflictError {
|
|
105
|
+
return new ConflictError({
|
|
106
|
+
message: `${describeUnconfirmedCloudflareAccount(accountId)} Nothing was changed.`,
|
|
107
|
+
action: UNCONFIRMED_CLOUDFLARE_ACCOUNT_ACTION,
|
|
108
|
+
detail: `looked for ${what} on account ${accountId}; the account id came from the environment and the project claims none`,
|
|
109
|
+
});
|
|
110
|
+
}
|