@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,368 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
8
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
9
|
+
import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
10
|
+
import { parse } from "comment-json";
|
|
11
|
+
import { capabilityLoadError } from "./loadFailure";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **Which capability owns which host Worker — the one production statement of it.**
|
|
15
|
+
*
|
|
16
|
+
* A capability that owns Workflows ships a committed `wrangler.jsonc` beside its worker entry, and
|
|
17
|
+
* `pithy <capability> provision` resolves that template into one deployable config per environment.
|
|
18
|
+
* Nine capabilities do; not one of them lives in `apps/`, so until now no host Worker had ever run
|
|
19
|
+
* under `pithy dev` and every locally enqueued email sat `pending` forever (pithy-sh/pithy#410).
|
|
20
|
+
*
|
|
21
|
+
* The mapping from a capability to its template, its entry module and its resolver existed only
|
|
22
|
+
* inside `hostTemplates.test.ts` — a test file, which nothing may import at runtime. This is that
|
|
23
|
+
* mapping as production code. `pithy dev` reads it and knows nothing about email, media or vector;
|
|
24
|
+
* a tenth capability adds one entry here and joins the dev set with no change to the dev command.
|
|
25
|
+
*
|
|
26
|
+
* ## Why every import is dynamic
|
|
27
|
+
*
|
|
28
|
+
* Six of the eight packages below are `devDependencies` of the CLI, because a capability is optional
|
|
29
|
+
* and the CLI must never hard-depend on one. So each entry resolves its resolver through a guarded
|
|
30
|
+
* `import()` at the moment the project turns out to compose it, and a package that is not installed
|
|
31
|
+
* becomes {@link capabilityLoadError}'s actionable refusal rather than an unresolved-module crash.
|
|
32
|
+
*
|
|
33
|
+
* ## What a dev resolution is, and what it deliberately is not
|
|
34
|
+
*
|
|
35
|
+
* {@link HostResolveContext} is capability-agnostic on purpose: project, environment, the app's base
|
|
36
|
+
* URL, the id each D1 binding resolves to, and the composed capability object when the project has
|
|
37
|
+
* one. Each entry maps that onto its own resolver's bespoke parameters, which is the shape the
|
|
38
|
+
* provisioners already have and the shape `hostTemplates.test.ts` already drives.
|
|
39
|
+
*
|
|
40
|
+
* A capability's *own* configuration (`MediaConfig`, `StorageConfig`, …) is read from the composed
|
|
41
|
+
* capability where the capability exposes it — email does, through `emailConfig` — and otherwise
|
|
42
|
+
* falls back to that capability's schema defaults. A capability that wants its adopter's tuning in
|
|
43
|
+
* the local host attaches it to its composed object the way `@pithy-sh/email` does; until it does,
|
|
44
|
+
* the local host runs on defaults and the deployed one runs on the adopter's config, because
|
|
45
|
+
* provisioning has the config object and `pithy dev` has only the `Capability`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** What one host resolution needs, stated without reference to any particular capability. */
|
|
49
|
+
export interface HostResolveContext {
|
|
50
|
+
/** The project name — the leading segment of every name the resolution derives. Never guessed. */
|
|
51
|
+
project: string;
|
|
52
|
+
/** The environment being resolved. `dev` for a local host; a managed name for a deploy. */
|
|
53
|
+
env: string;
|
|
54
|
+
/** The app Worker's origin for this environment — what callback links are built against. */
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
/**
|
|
57
|
+
* The database id a D1 binding resolves to. Under `dev` this answers the **binding name**, because
|
|
58
|
+
* that is wrangler's own local key (`getRemoteId(database_id) ?? binding`) and therefore the one
|
|
59
|
+
* `pithy migrate --env dev` filled. A host answering anything else opens an empty database.
|
|
60
|
+
*/
|
|
61
|
+
databaseId: (binding: string) => string;
|
|
62
|
+
/** The Secrets Store id. Absent locally — dev has no store, and the master key arrives in `.dev.vars`. */
|
|
63
|
+
storeId?: string;
|
|
64
|
+
/** The Cloudflare account id, which only the secrets manager's own resolver stamps into its vars. */
|
|
65
|
+
accountId?: string;
|
|
66
|
+
/** The composed capability object, when one of the project's Workers composes this capability. */
|
|
67
|
+
capability?: Capability;
|
|
68
|
+
/**
|
|
69
|
+
* No message may leave this machine. Set when `pithy dev`'s delivery preflight established that real
|
|
70
|
+
* delivery is impossible here — no Cloudflare login, or a from address nobody could onboard — so the
|
|
71
|
+
* host is resolved for its local simulator instead of a binding that would fail at startup.
|
|
72
|
+
*
|
|
73
|
+
* Capability-agnostic by wording and acted on today only by `email`, which is the one capability
|
|
74
|
+
* holding a binding that puts a message on the wire.
|
|
75
|
+
*/
|
|
76
|
+
simulateDelivery?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a capability would put on the wire from a developer's machine, when it puts anything there.
|
|
81
|
+
*
|
|
82
|
+
* On {@link HostWorkerSpec} rather than read off the composed capability by the dev command, so
|
|
83
|
+
* `pithy dev`'s delivery preflight asks every host the same question and branches on none of them.
|
|
84
|
+
* One capability answers today: `email` holds the kit's only binding that sends a message.
|
|
85
|
+
*/
|
|
86
|
+
export interface HostDeliveryIdentity {
|
|
87
|
+
/** The delivery mode the adopter's config selected. `simulator` is a choice, not a failure. */
|
|
88
|
+
requested: "remote" | "simulator";
|
|
89
|
+
/** The address messages are sent from. Its domain is what must be onboarded onto the service. */
|
|
90
|
+
fromAddress?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One capability's host Worker: where its template and entry live, and how a context resolves it. */
|
|
94
|
+
export interface HostWorkerSpec {
|
|
95
|
+
/** The capability's name — the key a composed `Capability` is matched against, and the host's label. */
|
|
96
|
+
readonly capability: string;
|
|
97
|
+
/** The worker entry module the host runs. Its sibling `wrangler.jsonc` is the template. */
|
|
98
|
+
readonly entry: string;
|
|
99
|
+
/** The npm package the entry ships in, named when the guarded import fails. */
|
|
100
|
+
readonly package: string;
|
|
101
|
+
/** Fill the template for one environment. Throws {@link capabilityLoadError} when the package is absent. */
|
|
102
|
+
resolve(template: WorkflowHostTemplate, context: HostResolveContext): Promise<WorkflowHostTemplate>;
|
|
103
|
+
/** What this host would send from a developer's machine, or `undefined` when it sends nothing. */
|
|
104
|
+
delivery?(capability: Capability | undefined): Promise<HostDeliveryIdentity | undefined>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The absolute path of the `wrangler.jsonc` committed beside a host's worker entry. */
|
|
108
|
+
export function hostTemplatePath(entry: string): string {
|
|
109
|
+
return join(dirname(fileURLToPath(import.meta.resolve(entry))), "wrangler.jsonc");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read a capability's committed template exactly as its provisioner does — resolved through the
|
|
114
|
+
* module graph rather than a relative path, so a moved package still resolves, and parsed with
|
|
115
|
+
* `comment-json` because the file is JSONC and heavily commented.
|
|
116
|
+
*/
|
|
117
|
+
export async function readHostTemplate(entry: string): Promise<WorkflowHostTemplate> {
|
|
118
|
+
return parse(await readFile(hostTemplatePath(entry), "utf8")) as unknown as WorkflowHostTemplate;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Run a guarded dynamic import, turning an absent optional package into an actionable refusal. */
|
|
122
|
+
async function load<T>(capability: string, pkg: string, importer: () => Promise<T>): Promise<T> {
|
|
123
|
+
try {
|
|
124
|
+
return await importer();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw capabilityLoadError(capability, pkg, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The three ids every host that reads a secret needs, mapped off the context in one place. */
|
|
131
|
+
function shared(context: HostResolveContext) {
|
|
132
|
+
return {
|
|
133
|
+
project: context.project,
|
|
134
|
+
env: context.env,
|
|
135
|
+
appDatabaseId: context.databaseId("DB"),
|
|
136
|
+
secretsDatabaseId: context.databaseId("SECRETS"),
|
|
137
|
+
// Empty rather than a placeholder: a dev resolution has no store, and the block is stripped
|
|
138
|
+
// before the config reaches disk. A marker left standing would read like a value nobody filled.
|
|
139
|
+
storeId: context.storeId ?? "",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every capability that ships a host Worker, and how one environment's config is filled.
|
|
145
|
+
*
|
|
146
|
+
* `@pithy-sh/leaderboard` is deliberately absent: its rank worker ships a complete template and a
|
|
147
|
+
* `RankRefreshWorkflow`, but no `resolveLeaderboardConfig` and no provisioner — there is nothing to
|
|
148
|
+
* drive, and driving it here would mean writing the resolver. `hostRegistry.test.ts` pins that
|
|
149
|
+
* absence against what is on disk, so it cannot become an oversight.
|
|
150
|
+
*/
|
|
151
|
+
export const HOST_WORKERS: readonly HostWorkerSpec[] = [
|
|
152
|
+
{
|
|
153
|
+
capability: "email",
|
|
154
|
+
entry: "@pithy-sh/email/src/workflows/worker",
|
|
155
|
+
package: "@pithy-sh/email",
|
|
156
|
+
async resolve(template, context) {
|
|
157
|
+
const [{ resolveEmailConfig }, { defaultTheme }, { isEmailCapability }] = await load(
|
|
158
|
+
"email",
|
|
159
|
+
"@pithy-sh/email",
|
|
160
|
+
() =>
|
|
161
|
+
Promise.all([
|
|
162
|
+
import("@pithy-sh/email/src/provision/resolveEmailConfig"),
|
|
163
|
+
import("@pithy-sh/email/src/templates/theme"),
|
|
164
|
+
import("@pithy-sh/email/src/capability"),
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
// The one capability that already hands its resolved config to whoever composed it. The theme
|
|
168
|
+
// and the delivery mode are both the adopter's, so the local host renders and sends exactly
|
|
169
|
+
// what the deployed one would.
|
|
170
|
+
const composed = context.capability && isEmailCapability(context.capability) ? context.capability : undefined;
|
|
171
|
+
return resolveEmailConfig(template as Parameters<typeof resolveEmailConfig>[0], {
|
|
172
|
+
...shared(context),
|
|
173
|
+
suppressionDatabaseId: context.databaseId("EMAIL_SUPPRESSIONS"),
|
|
174
|
+
// **The context's, never `email({ baseUrl })`.** That key is the *deployed* app's public
|
|
175
|
+
// origin and is required, so it is always set — and a local host resolved against it mints
|
|
176
|
+
// every tracked click, open pixel and unsubscribe link at production, signed with the local
|
|
177
|
+
// signing key. `context.baseUrl` is the origin for the environment being resolved, which is
|
|
178
|
+
// the app's own address in the one environment this resolver is used from.
|
|
179
|
+
baseUrl: context.baseUrl,
|
|
180
|
+
theme: composed?.emailConfig.theme ?? defaultTheme,
|
|
181
|
+
// The local host renders through the same catalogs the deployed one is stamped with, so a
|
|
182
|
+
// Spanish magic link looks the same on a developer's machine as it does in production. Empty
|
|
183
|
+
// when nothing composed an i18n capability, and then no var is written at all.
|
|
184
|
+
//
|
|
185
|
+
// True only because `discoverHostWorkers` assembles the set before handing a capability here —
|
|
186
|
+
// `hostCatalogs()` answers `{}` on one whose `compose` hook has not run, and this sentence was
|
|
187
|
+
// false for exactly as long as it did not (pithy-sh/pithy#441).
|
|
188
|
+
messages: composed?.hostCatalogs() ?? {},
|
|
189
|
+
devDelivery: context.simulateDelivery ? "simulator" : composed?.emailConfig.devDelivery,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
async delivery(capability) {
|
|
193
|
+
if (!capability) return undefined;
|
|
194
|
+
const { isEmailCapability } = await load(
|
|
195
|
+
"email",
|
|
196
|
+
"@pithy-sh/email",
|
|
197
|
+
() => import("@pithy-sh/email/src/capability"),
|
|
198
|
+
);
|
|
199
|
+
if (!isEmailCapability(capability)) return undefined;
|
|
200
|
+
return { requested: capability.emailConfig.devDelivery, fromAddress: capability.emailConfig.fromAddress };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
capability: "media",
|
|
205
|
+
entry: "@pithy-sh/media/src/workflows/worker",
|
|
206
|
+
package: "@pithy-sh/media",
|
|
207
|
+
async resolve(template, context) {
|
|
208
|
+
const [{ resolveMediaConfig }, { MediaConfig }] = await load("media", "@pithy-sh/media", () =>
|
|
209
|
+
Promise.all([
|
|
210
|
+
import("@pithy-sh/media/src/provision/resolveMediaConfig"),
|
|
211
|
+
import("@pithy-sh/media/src/config/config"),
|
|
212
|
+
]),
|
|
213
|
+
);
|
|
214
|
+
const mediaConfig = MediaConfig.parse({});
|
|
215
|
+
return resolveMediaConfig(template, {
|
|
216
|
+
...shared(context),
|
|
217
|
+
resources: {
|
|
218
|
+
bucketName: resourceNames(context.project).env(context.env).r2("MEDIA"),
|
|
219
|
+
// Records live in D1 by default, and the KV binding is dropped rather than pointed at a
|
|
220
|
+
// namespace nothing created. `recordStore` decides; the resolver reads it.
|
|
221
|
+
kvNamespaceId:
|
|
222
|
+
mediaConfig.recordStore === "kv" ? resourceNames(context.project).env(context.env).kv("MEDIA") : null,
|
|
223
|
+
},
|
|
224
|
+
mediaConfig,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
capability: "storage",
|
|
230
|
+
entry: "@pithy-sh/storage/src/workflows/worker",
|
|
231
|
+
package: "@pithy-sh/storage",
|
|
232
|
+
async resolve(template, context) {
|
|
233
|
+
const [{ resolveStorageConfig }, { StorageConfig }] = await load("storage", "@pithy-sh/storage", () =>
|
|
234
|
+
Promise.all([
|
|
235
|
+
import("@pithy-sh/storage/src/provision/resolveStorageConfig"),
|
|
236
|
+
import("@pithy-sh/storage/src/config/config"),
|
|
237
|
+
]),
|
|
238
|
+
);
|
|
239
|
+
return resolveStorageConfig(template, {
|
|
240
|
+
...shared(context),
|
|
241
|
+
resources: { bucketName: resourceNames(context.project).env(context.env).r2("STORAGE") },
|
|
242
|
+
storageConfig: StorageConfig.parse({}),
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
capability: "payments",
|
|
248
|
+
entry: "@pithy-sh/payments/src/workflows/worker",
|
|
249
|
+
package: "@pithy-sh/payments",
|
|
250
|
+
async resolve(template, context) {
|
|
251
|
+
const [{ resolvePaymentsConfig }, { PaymentsConfig }, { isPaymentsCapability }] = await load(
|
|
252
|
+
"payments",
|
|
253
|
+
"@pithy-sh/payments",
|
|
254
|
+
() =>
|
|
255
|
+
Promise.all([
|
|
256
|
+
import("@pithy-sh/payments/src/provision/resolvePaymentsConfig"),
|
|
257
|
+
import("@pithy-sh/payments/src/config/config"),
|
|
258
|
+
import("@pithy-sh/payments/src/capability"),
|
|
259
|
+
]),
|
|
260
|
+
);
|
|
261
|
+
// Payments hands its resolved config to whoever composed it, the way email does — so the local
|
|
262
|
+
// reconcile host runs the adopter's own catalog and their own `billingSubject`, and a dev pass
|
|
263
|
+
// narrows to the holder kind the project actually bills.
|
|
264
|
+
//
|
|
265
|
+
// The fallback is a project that composes no payments capability at all. It cannot be
|
|
266
|
+
// `PaymentsConfig.parse({})` any more: `billingSubject` is required precisely because nothing may
|
|
267
|
+
// pick it silently (#412), and an empty catalog has nobody to ask. `user` is stated here as what it
|
|
268
|
+
// is — a placeholder for a local host with no catalog behind it, which reaches no deployed Worker,
|
|
269
|
+
// because `pithy payments provision` resolves the deployed one from the adopter's config object.
|
|
270
|
+
const composed = context.capability && isPaymentsCapability(context.capability) ? context.capability : undefined;
|
|
271
|
+
return resolvePaymentsConfig(template, {
|
|
272
|
+
...shared(context),
|
|
273
|
+
paymentsConfig: composed?.paymentsConfig ?? PaymentsConfig.parse({ billingSubject: "user" }),
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
capability: "support",
|
|
279
|
+
entry: "@pithy-sh/support/src/workflows/worker",
|
|
280
|
+
package: "@pithy-sh/support",
|
|
281
|
+
async resolve(template, context) {
|
|
282
|
+
const [{ resolveSupportConfig }, { SupportConfig }] = await load("support", "@pithy-sh/support", () =>
|
|
283
|
+
Promise.all([
|
|
284
|
+
import("@pithy-sh/support/src/provision/resolveSupportConfig"),
|
|
285
|
+
import("@pithy-sh/support/src/config/config"),
|
|
286
|
+
]),
|
|
287
|
+
);
|
|
288
|
+
return resolveSupportConfig(template, {
|
|
289
|
+
project: context.project,
|
|
290
|
+
env: context.env,
|
|
291
|
+
appDatabaseId: context.databaseId("DB"),
|
|
292
|
+
supportConfig: SupportConfig.parse({}),
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
capability: "testers",
|
|
298
|
+
entry: "@pithy-sh/testers/src/workflows/worker",
|
|
299
|
+
package: "@pithy-sh/testers",
|
|
300
|
+
async resolve(template, context) {
|
|
301
|
+
const [{ resolveTestersConfig }, { TestersConfig }] = await load("testers", "@pithy-sh/testers", () =>
|
|
302
|
+
Promise.all([
|
|
303
|
+
import("@pithy-sh/testers/src/provision/resolveTestersConfig"),
|
|
304
|
+
import("@pithy-sh/testers/src/config/config"),
|
|
305
|
+
]),
|
|
306
|
+
);
|
|
307
|
+
return resolveTestersConfig(template, {
|
|
308
|
+
project: context.project,
|
|
309
|
+
env: context.env,
|
|
310
|
+
appDatabaseId: context.databaseId("DB"),
|
|
311
|
+
suppressionDatabaseId: context.databaseId("EMAIL_SUPPRESSIONS"),
|
|
312
|
+
testersConfig: TestersConfig.parse({}),
|
|
313
|
+
// No sending identity locally. Undefined is the capability's own legitimate state — the pass
|
|
314
|
+
// advances roster state and sends nothing — and is far better than a default address, which
|
|
315
|
+
// would send from a domain the adopter's DKIM does not cover.
|
|
316
|
+
email: undefined,
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
capability: "vector",
|
|
322
|
+
entry: "@pithy-sh/vector/src/workflows/worker",
|
|
323
|
+
package: "@pithy-sh/vector",
|
|
324
|
+
async resolve(template, context) {
|
|
325
|
+
const [{ resolveVectorConfig }, { VectorConfig }] = await load("vector", "@pithy-sh/vector", () =>
|
|
326
|
+
Promise.all([
|
|
327
|
+
import("@pithy-sh/vector/src/provision/resolveVectorConfig"),
|
|
328
|
+
import("@pithy-sh/vector/src/config/config"),
|
|
329
|
+
]),
|
|
330
|
+
);
|
|
331
|
+
const config = VectorConfig.parse({});
|
|
332
|
+
const names = resourceNames(context.project).env(context.env);
|
|
333
|
+
return resolveVectorConfig(template, {
|
|
334
|
+
project: context.project,
|
|
335
|
+
env: context.env,
|
|
336
|
+
appDatabaseId: context.databaseId("DB"),
|
|
337
|
+
indexNames: Object.fromEntries(
|
|
338
|
+
Object.keys(config.indexes).map((index) => [index, names.vectorizeIndex(index)]),
|
|
339
|
+
),
|
|
340
|
+
config,
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
capability: "secrets",
|
|
346
|
+
entry: "@pithy-sh/secrets/src/manager/worker",
|
|
347
|
+
package: "@pithy-sh/secrets",
|
|
348
|
+
async resolve(template, context) {
|
|
349
|
+
const { resolveManagerConfig } = await load(
|
|
350
|
+
"secrets",
|
|
351
|
+
"@pithy-sh/secrets",
|
|
352
|
+
() => import("@pithy-sh/secrets/src/provision/resolveManagerConfig"),
|
|
353
|
+
);
|
|
354
|
+
return resolveManagerConfig(template as Parameters<typeof resolveManagerConfig>[0], {
|
|
355
|
+
project: context.project,
|
|
356
|
+
env: context.env,
|
|
357
|
+
databaseId: context.databaseId("SECRETS"),
|
|
358
|
+
storeId: context.storeId ?? "",
|
|
359
|
+
accountId: context.accountId ?? "",
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
/** The host Worker a capability owns, or `undefined` when it owns none (`auth`, `audit`, `turnstile`, …). */
|
|
366
|
+
export function hostWorkerFor(capability: string): HostWorkerSpec | undefined {
|
|
367
|
+
return HOST_WORKERS.find((spec) => spec.capability === capability);
|
|
368
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
causeMessage,
|
|
6
|
+
isBuildFailureWrapper,
|
|
7
|
+
prop,
|
|
8
|
+
rootCause,
|
|
9
|
+
safeReason,
|
|
10
|
+
unresolvedSpecifier,
|
|
11
|
+
} from "@pithy-sh/core/src/error/cause";
|
|
12
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Why an optional capability would not load — and therefore what may be said about it (#217).
|
|
16
|
+
*
|
|
17
|
+
* Every capability loader in this folder is the same shape: several `import()` calls in one `try`, and
|
|
18
|
+
* one `catch`. Fourteen of them answered that catch with ``Run `pithy add <cap>` `` whatever went
|
|
19
|
+
* wrong. That sentence is right for exactly one of the failures the catch admits, and **wrong precisely
|
|
20
|
+
* when the capability is installed and one of its own transitive dependencies is not** — #207's bug, one
|
|
21
|
+
* level down the dependency graph. An adopter runs `pithy add payments`, the package is already there,
|
|
22
|
+
* nothing changes, and the resolver's message — which named `stripe` — went to `detail` and stopped.
|
|
23
|
+
*
|
|
24
|
+
* `manifests.ts` worked this out first and wrote it down: *a manifest that is there and will not open is
|
|
25
|
+
* not "not installed"*. Same sentence, one level up. This is that rule with a function behind it.
|
|
26
|
+
*
|
|
27
|
+
* The convention it implements is in `docs/CONVENTIONS.md` §Refusals: **a `catch` reachable by more than
|
|
28
|
+
* one underlying failure may not name a single specific remedy.** Classify, or hedge.
|
|
29
|
+
*/
|
|
30
|
+
export type CapabilityLoadKind =
|
|
31
|
+
/** The capability's own package does not resolve. `pithy add <cap>` is right, and earned. */
|
|
32
|
+
| "not-installed"
|
|
33
|
+
/** The capability resolves; something it imports does not. `pithy add` cannot fix this. */
|
|
34
|
+
| "dependency-unresolved"
|
|
35
|
+
/** Something does not resolve, and nothing names which package. Both remedies, neither asserted. */
|
|
36
|
+
| "unresolved-import"
|
|
37
|
+
/** The package is there and will not load — bad subpath, parse error, or its own throw. */
|
|
38
|
+
| "broken"
|
|
39
|
+
/** None of the above. No remedy at all: a wrong action is worse than no action. */
|
|
40
|
+
| "unknown";
|
|
41
|
+
|
|
42
|
+
/** What a loader may say about a capability that would not load. */
|
|
43
|
+
export interface CapabilityLoadFailure {
|
|
44
|
+
/** Which failure this was, decided from the cause and never asserted over it. */
|
|
45
|
+
kind: CapabilityLoadKind;
|
|
46
|
+
/** Client-safe refusal text. Only `not-installed` is allowed to claim the capability is absent. */
|
|
47
|
+
message: string;
|
|
48
|
+
/** The remedy, chosen from the kind. Carries no absolute path, no source line, no stack. */
|
|
49
|
+
action: string;
|
|
50
|
+
/** Throw-site context: kind, what was being resolved, and the raw cause. Never rendered to a client. */
|
|
51
|
+
detail: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The message, de-colored, for this classifier's own pattern tests — never for output.
|
|
56
|
+
*
|
|
57
|
+
* What may be *said* is core's {@link safeReason}, and only core's. The filter that decides whether a
|
|
58
|
+
* runtime's string is fit to show lived here, in `project/config.ts` and in the vite plugin, in three
|
|
59
|
+
* near-verbatim copies; whether a string carries a path or a stack frame is a property of the string, and
|
|
60
|
+
* three surfaces cannot hold three answers to it without two of them being wrong (#228). What stays here
|
|
61
|
+
* is the policy: which causes this loader recognizes, and what it tells an adopter about each.
|
|
62
|
+
*/
|
|
63
|
+
function rawMessage(cause: unknown): string {
|
|
64
|
+
return causeMessage(cause) ?? "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A resolution failure — the module graph, not the module's own code. */
|
|
68
|
+
function isUnresolvedImport(cause: unknown): boolean {
|
|
69
|
+
const code = prop(cause, "code");
|
|
70
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || code === "ERR_UNSUPPORTED_DIR_IMPORT") {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (prop(cause, "name") === "ResolveMessage") return true;
|
|
74
|
+
return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(
|
|
75
|
+
rawMessage(cause),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A subpath the installed package does not export.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately **not** an unresolved import here, though Node groups them: the package resolved, so it
|
|
83
|
+
* is installed, and `pithy add` would reinstall the same broken export map. Ejected from the resolution
|
|
84
|
+
* branch on purpose — this is the one resolver error that proves the capability is present.
|
|
85
|
+
*/
|
|
86
|
+
function isBadSubpath(cause: unknown): boolean {
|
|
87
|
+
const code = prop(cause, "code");
|
|
88
|
+
return code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || /is not defined by "exports"/.test(rawMessage(cause));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The package's own source would not parse or build. */
|
|
92
|
+
function isParseError(cause: unknown): boolean {
|
|
93
|
+
const name = prop(cause, "name");
|
|
94
|
+
if (name === "SyntaxError" || name === "BuildMessage") return true;
|
|
95
|
+
if (prop(cause, "code") === "PARSE_ERROR") return true;
|
|
96
|
+
// Bun's build wrapper with its diagnostics already dropped — the shape every caller after the first
|
|
97
|
+
// sees, since a failed module is cached and re-thrown emptied out. It proves a build produced
|
|
98
|
+
// diagnostics, so it is a parse error with no reason to quote. See core's `cause.ts` (#223).
|
|
99
|
+
if (isBuildFailureWrapper(cause)) return true;
|
|
100
|
+
return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(
|
|
101
|
+
rawMessage(cause),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `@pithy-sh/payments` from `@pithy-sh/payments/src/workflows/worker` — the package, not the subpath. */
|
|
106
|
+
function packageOf(specifier: string): string {
|
|
107
|
+
const parts = specifier.split("/");
|
|
108
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Choose a capability loader's refusal **from** the failure rather than asserting one over it (#217).
|
|
113
|
+
*
|
|
114
|
+
* @param capability the name `pithy add <name>` takes — `payments`, `vector`, `storage`.
|
|
115
|
+
* @param target what the loader was resolving. Its package half decides "ours or theirs"; the rest is
|
|
116
|
+
* `detail` only, since a deep subpath is our implementation and not the adopter's business.
|
|
117
|
+
* @param cause whatever the `try` caught. Read duck-typed, never `instanceof`.
|
|
118
|
+
*/
|
|
119
|
+
export function classifyCapabilityLoadFailure(
|
|
120
|
+
capability: string,
|
|
121
|
+
target: string,
|
|
122
|
+
wrapped: unknown,
|
|
123
|
+
): CapabilityLoadFailure {
|
|
124
|
+
// Bun hands `import()` failures over inside an `AggregateError`. Classify what is inside it.
|
|
125
|
+
const cause = rootCause(wrapped);
|
|
126
|
+
const pkg = packageOf(target);
|
|
127
|
+
const raw = causeMessage(cause) ?? String(cause);
|
|
128
|
+
const failure = (kind: CapabilityLoadKind, message: string, action: string): CapabilityLoadFailure => ({
|
|
129
|
+
kind,
|
|
130
|
+
message,
|
|
131
|
+
action,
|
|
132
|
+
detail: `${kind}: ${target} — ${raw}`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Present, and its export map or its source is wrong. Checked before resolution, because Node reports
|
|
136
|
+
// a bad subpath with a resolver code and the package plainly resolved for it to be reached.
|
|
137
|
+
if (isBadSubpath(cause)) {
|
|
138
|
+
return failure(
|
|
139
|
+
"broken",
|
|
140
|
+
`The ${capability} capability is installed but incomplete.`,
|
|
141
|
+
`${pkg} does not export what this command needs. Reinstall it, or report this to its maintainer.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isUnresolvedImport(cause)) {
|
|
146
|
+
const specifier = unresolvedSpecifier(cause);
|
|
147
|
+
if (specifier === undefined) {
|
|
148
|
+
return failure(
|
|
149
|
+
"unresolved-import",
|
|
150
|
+
`The ${capability} capability could not be loaded.`,
|
|
151
|
+
`An import did not resolve. Run \`pithy add ${capability}\` if it is not installed, or bun install if it is.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (packageOf(specifier) === pkg) {
|
|
155
|
+
return failure(
|
|
156
|
+
"not-installed",
|
|
157
|
+
`The ${capability} capability is not installed.`,
|
|
158
|
+
`Run \`pithy add ${capability}\`, then re-run this command.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return failure(
|
|
162
|
+
"dependency-unresolved",
|
|
163
|
+
`The ${capability} capability could not be loaded.`,
|
|
164
|
+
`${pkg} is installed, but nothing resolves "${specifier}". Install the project's dependencies (bun install), then re-run this command.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isParseError(cause)) {
|
|
169
|
+
// `safeReason` can answer nothing, and Bun's build wrapper — a count and an absolute path, and
|
|
170
|
+
// never anything else — is one of the reasons it does. That suppression is core's, written once
|
|
171
|
+
// rather than here and in two other classifiers that each had to be found and patched (#223, #228).
|
|
172
|
+
const reason = safeReason(cause);
|
|
173
|
+
return failure(
|
|
174
|
+
"broken",
|
|
175
|
+
`The ${capability} capability is installed and will not load.`,
|
|
176
|
+
`${pkg} does not parse${reason ? `: ${reason}` : ""}. Reinstall it, or report this to its maintainer.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Anything carrying a message, whether or not it extends `Error` — the package's own throw.
|
|
181
|
+
if (causeMessage(cause) !== undefined) {
|
|
182
|
+
const reason = safeReason(cause);
|
|
183
|
+
return failure(
|
|
184
|
+
"broken",
|
|
185
|
+
`The ${capability} capability is installed and will not load.`,
|
|
186
|
+
reason
|
|
187
|
+
? `${pkg} threw while loading: ${reason}. Fix that, or report it to its maintainer.`
|
|
188
|
+
: `${pkg} threw while loading. Run \`pithy doctor\` to see what the project resolves.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return failure(
|
|
193
|
+
"unknown",
|
|
194
|
+
`The ${capability} capability could not be loaded.`,
|
|
195
|
+
`Run \`pithy doctor\` to check what the project resolves, then re-run this command.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The refusal a capability loader throws — {@link classifyCapabilityLoadFailure} with the error around it.
|
|
201
|
+
*
|
|
202
|
+
* `ValidationError` is kept from the fourteen sites this replaces: the code an adopter's tooling matches
|
|
203
|
+
* on does not change because the sentence got honest.
|
|
204
|
+
*/
|
|
205
|
+
export function capabilityLoadError(capability: string, target: string, cause: unknown): ValidationError {
|
|
206
|
+
const { message, action, detail } = classifyCapabilityLoadFailure(capability, target, cause);
|
|
207
|
+
return new ValidationError({ message, action, detail }, { cause });
|
|
208
|
+
}
|