@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,515 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import type { R2Credentials } from "@pithy-sh/cloudflare/src/r2/r2Credentials";
|
|
9
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
11
|
+
import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
12
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
13
|
+
import { parse } from "comment-json";
|
|
14
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
15
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
16
|
+
import { runWrangler } from "../project/wrangler";
|
|
17
|
+
import { capabilityLoadError } from "./loadFailure";
|
|
18
|
+
import { deleteR2BucketWithContents } from "./r2Bucket";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The live support provisioner — the Cloudflare + wrangler implementation behind `@pithy-sh/support`'s
|
|
22
|
+
* `SupportProvisioner` seam. Control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: the CF API
|
|
23
|
+
* only via that client) and are each idempotent; the classification worker's deploy shells out to wrangler
|
|
24
|
+
* with the bootstrap token.
|
|
25
|
+
*
|
|
26
|
+
* `@pithy-sh/support` is an **optional** capability, so the CLI must not hard-depend on it. Types come in
|
|
27
|
+
* through type-only imports (erased at build), and every runtime value comes through {@link loadSupport} —
|
|
28
|
+
* a guarded dynamic import that turns "the package isn't installed" into an actionable error rather than an
|
|
29
|
+
* unresolved-module crash.
|
|
30
|
+
*
|
|
31
|
+
* **No secret is written here, and that is the whole shape of this file.** The classification worker reads
|
|
32
|
+
* a message and writes a label over the `AI` binding, so it holds no credential at all — which is why
|
|
33
|
+
* provisioning support is three steps (a bucket, a worker per environment, a routing rule) where media is
|
|
34
|
+
* five. The one credential support does use, the R2 key pair its attachment presigning needs, belongs to
|
|
35
|
+
* `@pithy-sh/storage`'s `ObjectStore` and is written by `pithy storage provision`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The support runtime surface provisioning needs, loaded from the project's own install. */
|
|
39
|
+
type SupportProvisionModule = typeof import("@pithy-sh/support/src/provision/provisionSupport");
|
|
40
|
+
type SupportResolveModule = typeof import("@pithy-sh/support/src/provision/resolveSupportConfig");
|
|
41
|
+
type SupportCapabilityModule = typeof import("@pithy-sh/support/src/capability");
|
|
42
|
+
type SupportConfigModule = typeof import("@pithy-sh/support/src/config/config");
|
|
43
|
+
|
|
44
|
+
/** The provisioner seams, referenced by type only so the CLI gains no dependency on the package. */
|
|
45
|
+
type SupportProvisioner = import("@pithy-sh/support/src/provision/provisionSupport").SupportProvisioner;
|
|
46
|
+
type SupportDeprovisioner = import("@pithy-sh/support/src/provision/provisionSupport").SupportDeprovisioner;
|
|
47
|
+
type SupportConfig = import("@pithy-sh/support/src/config/config").SupportConfig;
|
|
48
|
+
|
|
49
|
+
/** Everything `pithy support` loads out of the optional package, in one guarded import. */
|
|
50
|
+
export type SupportModule = SupportProvisionModule &
|
|
51
|
+
SupportResolveModule &
|
|
52
|
+
SupportCapabilityModule &
|
|
53
|
+
SupportConfigModule;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load `@pithy-sh/support` from the project's own install. The one place the optional dependency is
|
|
57
|
+
* resolved, so a project that has not added support gets one clear instruction instead of a module error
|
|
58
|
+
* from whichever call site happened to run first.
|
|
59
|
+
*/
|
|
60
|
+
export async function loadSupport(): Promise<SupportModule> {
|
|
61
|
+
try {
|
|
62
|
+
const [provision, resolve, capability, config] = await Promise.all([
|
|
63
|
+
import("@pithy-sh/support/src/provision/provisionSupport"),
|
|
64
|
+
import("@pithy-sh/support/src/provision/resolveSupportConfig"),
|
|
65
|
+
import("@pithy-sh/support/src/capability"),
|
|
66
|
+
// `supportNeedsBucket` comes from here. The predicate must be the capability's own — the CLI
|
|
67
|
+
// holding a second copy is exactly the drift that let provisioning and declaration disagree.
|
|
68
|
+
import("@pithy-sh/support/src/config/config"),
|
|
69
|
+
]);
|
|
70
|
+
return { ...provision, ...resolve, ...capability, ...config };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw capabilityLoadError("support", "@pithy-sh/support", error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The search-index lifecycle and the typed database it runs against, from the optional package.
|
|
78
|
+
*
|
|
79
|
+
* A second guarded import beside {@link loadSupport} rather than an addition to it, because these two
|
|
80
|
+
* modules are only needed by `ensureSearchIndex` — and `loadSupport` is already on the hot path of
|
|
81
|
+
* every other step, where paying for two more dynamic imports buys nothing.
|
|
82
|
+
*/
|
|
83
|
+
async function loadSupportSearch(): Promise<
|
|
84
|
+
typeof import("@pithy-sh/support/src/store/searchIndex") &
|
|
85
|
+
typeof import("@pithy-sh/support/src/data/tables") &
|
|
86
|
+
typeof import("@pithy-sh/support/src/store/search")
|
|
87
|
+
> {
|
|
88
|
+
try {
|
|
89
|
+
const [searchIndex, tables, search] = await Promise.all([
|
|
90
|
+
import("@pithy-sh/support/src/store/searchIndex"),
|
|
91
|
+
import("@pithy-sh/support/src/data/tables"),
|
|
92
|
+
import("@pithy-sh/support/src/store/search"),
|
|
93
|
+
]);
|
|
94
|
+
return { ...searchIndex, ...tables, ...search };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw capabilityLoadError("support", "@pithy-sh/support", error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The R2 bucket attachments and raw messages live in — `<project>-global-support`.
|
|
102
|
+
*
|
|
103
|
+
* **One bucket for the project, not one per environment**, which is the opposite of what `pithy media` and
|
|
104
|
+
* `pithy storage` do and is the seam's own shape (`ensureBucket()` takes no environment). It follows from
|
|
105
|
+
* where the bytes are written from: the `SUPPORT_BUCKET` binding hangs off the *app* worker that receives
|
|
106
|
+
* the mail, and a Worker addresses a bucket by the name its own `wrangler.jsonc` gives, per environment. So
|
|
107
|
+
* the environments are separated by the binding an operator points at a bucket, and this command's job is
|
|
108
|
+
* to make sure one exists to point at. `global` sits in the environment slot to say that out loud rather
|
|
109
|
+
* than by omission.
|
|
110
|
+
*
|
|
111
|
+
* A function rather than a constant, because the project segment is not a constant — and it is what makes
|
|
112
|
+
* `ensureBucket`'s find-then-create safe. R2's namespace is flat and account-wide, so the old fixed
|
|
113
|
+
* `pithy-support` meant a second Pithy project in the same account adopted this one's bucket: two products'
|
|
114
|
+
* customer correspondence in one place, and either teardown deleting both.
|
|
115
|
+
*/
|
|
116
|
+
export function supportBucketName(project: string): string {
|
|
117
|
+
return resourceNames(project).global.r2("support");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The per-environment resource ids the support classification worker binds, resolved by the caller. */
|
|
121
|
+
export interface SupportEnvResources {
|
|
122
|
+
/** The app database id for this environment — where the support tables live. */
|
|
123
|
+
appDatabaseId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve the per-environment resources for the support worker (from the app Worker's wrangler config). */
|
|
127
|
+
export type ResolveSupportEnv = (env: ManagedEnvironment) => Promise<SupportEnvResources>;
|
|
128
|
+
|
|
129
|
+
/** The inbound routing a rule is created for: the zone, the address it matches, and the Worker it feeds. */
|
|
130
|
+
export interface SupportRouting {
|
|
131
|
+
/** Zone the rule lives on. Email Routing must already be enabled on it — enabling it moves the zone's MX. */
|
|
132
|
+
zoneId: string;
|
|
133
|
+
/** The exact recipient address the rule matches. */
|
|
134
|
+
address: string;
|
|
135
|
+
/** The deployed app worker the matched mail is delivered to — the one running the `email()` handler. */
|
|
136
|
+
appWorkerName: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface CloudflareSupportProvisionerOptions {
|
|
140
|
+
cf: CloudflareClients;
|
|
141
|
+
/**
|
|
142
|
+
* The account this provisions into, and what vouches for it (#378).
|
|
143
|
+
*
|
|
144
|
+
* Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
|
|
145
|
+
* already held while a find-or-create read an empty listing as "this account has none" and minted a
|
|
146
|
+
* real resource in whichever account the shell had named. The id is still here — `account.accountId` —
|
|
147
|
+
* and it now travels with the answer to "who says so".
|
|
148
|
+
*/
|
|
149
|
+
account: ConfirmedAccount;
|
|
150
|
+
/**
|
|
151
|
+
* The project name, from `requireProjectName(await loadProject(projectDir))` — never
|
|
152
|
+
* `resolveProjectName`. The bucket, every environment's worker, and the inbound routing rule all lead
|
|
153
|
+
* with it, and the bucket is *found by name and reused*: a guessed value adopts another project's
|
|
154
|
+
* correspondence.
|
|
155
|
+
*/
|
|
156
|
+
project: string;
|
|
157
|
+
/** The broad bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
|
|
158
|
+
apiToken: string;
|
|
159
|
+
/**
|
|
160
|
+
* The app's resolved support config. Decides whether a bucket is needed at all, and travels into the
|
|
161
|
+
* worker's `SUPPORT_CONFIG` var so an adopter's own categories reach the prompt as data.
|
|
162
|
+
*/
|
|
163
|
+
supportConfig: SupportConfig;
|
|
164
|
+
/** Resolve the per-env app DB id — injected so it is testable + decoupled from where the config lives. */
|
|
165
|
+
resolveEnv: ResolveSupportEnv;
|
|
166
|
+
/**
|
|
167
|
+
* Optional inbound routing. Absent → the routing step is skipped, because **enabling Email Routing points
|
|
168
|
+
* a zone's MX at Cloudflare**: a rule on the wrong zone moves an adopter's real mail off their provider,
|
|
169
|
+
* which is not a mistake a provisioning command gets to make on their behalf.
|
|
170
|
+
*/
|
|
171
|
+
routing?: SupportRouting;
|
|
172
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
173
|
+
audit?: CliAuditEmit;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The live {@link SupportProvisioner}. Every step is idempotent, so provisioning is safe to re-run. */
|
|
177
|
+
export class CloudflareSupportProvisioner implements SupportProvisioner {
|
|
178
|
+
readonly #cf: CloudflareClients;
|
|
179
|
+
readonly #account: ConfirmedAccount;
|
|
180
|
+
readonly #project: string;
|
|
181
|
+
readonly #apiToken: string;
|
|
182
|
+
readonly #supportConfig: SupportConfig;
|
|
183
|
+
readonly #resolveEnv: ResolveSupportEnv;
|
|
184
|
+
readonly #routing: SupportRouting | undefined;
|
|
185
|
+
readonly #audit: CliAuditEmit;
|
|
186
|
+
|
|
187
|
+
constructor(options: CloudflareSupportProvisionerOptions) {
|
|
188
|
+
this.#cf = options.cf;
|
|
189
|
+
this.#account = options.account;
|
|
190
|
+
this.#project = options.project;
|
|
191
|
+
this.#apiToken = options.apiToken;
|
|
192
|
+
this.#supportConfig = options.supportConfig;
|
|
193
|
+
this.#resolveEnv = options.resolveEnv;
|
|
194
|
+
this.#routing = options.routing;
|
|
195
|
+
this.#audit = options.audit ?? (async () => {});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy the Workflow-hosting worker. */
|
|
199
|
+
async preflight(): Promise<void> {
|
|
200
|
+
if (!(await this.#cf.workers().accountSubdomain())) {
|
|
201
|
+
throw new ValidationError({
|
|
202
|
+
message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
|
|
203
|
+
action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reuse the bucket if it exists, otherwise create it — unless nothing will be written to it.
|
|
210
|
+
*
|
|
211
|
+
* **Gated on `supportNeedsBucket`, which is the same predicate the capability declares the binding
|
|
212
|
+
* with.** Three settings put bytes here and each has its own writer: mail attachments, the raw MIME
|
|
213
|
+
* copy, and an in-app submission's files. This gate asked about the first two and never about the
|
|
214
|
+
* third, so a project that wanted uploads but no mail attachments got a `SUPPORT_BUCKET` binding
|
|
215
|
+
* pointing at a bucket nothing had created, and every submitted file was dropped with a warning
|
|
216
|
+
* (#440). Asking the capability's own predicate is what keeps provisioning and declaration from
|
|
217
|
+
* drifting again — a fourth writer teaches both at once.
|
|
218
|
+
*
|
|
219
|
+
* Why the raw copy earns a flag of its own: keying on `attachments.enabled` alone meant an adopter
|
|
220
|
+
* who turned off attachment storage also, silently, lost the immutable raw MIME that makes
|
|
221
|
+
* re-parsing and re-sanitizing possible — a property the message schema documents as load-bearing.
|
|
222
|
+
*/
|
|
223
|
+
async ensureBucket(): Promise<{ bucket: string; created: boolean; skipped: boolean }> {
|
|
224
|
+
const { supportNeedsBucket } = await loadSupport();
|
|
225
|
+
const name = supportBucketName(this.#project);
|
|
226
|
+
if (!supportNeedsBucket(this.#supportConfig)) {
|
|
227
|
+
return { bucket: name, created: false, skipped: true };
|
|
228
|
+
}
|
|
229
|
+
const existing = await this.#cf.r2Provisioner().findBucketByName(name);
|
|
230
|
+
if (existing) return { bucket: existing.name, created: false, skipped: false };
|
|
231
|
+
const created = await this.#cf.r2Provisioner().createBucket(name);
|
|
232
|
+
await this.#audit({
|
|
233
|
+
environment: "global",
|
|
234
|
+
action: "support/bucket_created",
|
|
235
|
+
outcome: "success",
|
|
236
|
+
severity: "info",
|
|
237
|
+
resourceType: "cf_r2_bucket",
|
|
238
|
+
resourceId: created.name,
|
|
239
|
+
// R2 exposes no tags through the API, so the name is the whole ownership record on the bucket
|
|
240
|
+
// itself; this is the only place a human can later read which project it belongs to.
|
|
241
|
+
metadata: { name: created.name },
|
|
242
|
+
});
|
|
243
|
+
return { bucket: created.name, created: true, skipped: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Resolve the env's wrangler config from the committed template + the app DB id, then `wrangler deploy`. */
|
|
247
|
+
async deployWorker(env: ManagedEnvironment): Promise<void> {
|
|
248
|
+
const { supportWorkerName, resolveSupportConfig } = await loadSupport();
|
|
249
|
+
const { appDatabaseId } = await this.#resolveEnv(env);
|
|
250
|
+
const dir = supportWorkerDir();
|
|
251
|
+
const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
|
|
252
|
+
const config = resolveSupportConfig(template, {
|
|
253
|
+
project: this.#project,
|
|
254
|
+
env,
|
|
255
|
+
appDatabaseId,
|
|
256
|
+
supportConfig: this.#supportConfig,
|
|
257
|
+
});
|
|
258
|
+
const configPath = join(dir, `.wrangler.${env}.json`);
|
|
259
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
260
|
+
try {
|
|
261
|
+
await runWrangler(["deploy", "--config", configPath], {
|
|
262
|
+
cwd: dir,
|
|
263
|
+
env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#account.accountId },
|
|
264
|
+
});
|
|
265
|
+
await this.#audit({
|
|
266
|
+
environment: env,
|
|
267
|
+
action: "support/worker_deployed",
|
|
268
|
+
outcome: "success",
|
|
269
|
+
severity: "info",
|
|
270
|
+
resourceType: "cf_worker",
|
|
271
|
+
resourceId: supportWorkerName(this.#project, env),
|
|
272
|
+
});
|
|
273
|
+
} catch (error) {
|
|
274
|
+
await this.#audit({
|
|
275
|
+
environment: env,
|
|
276
|
+
action: "support/worker_deployed",
|
|
277
|
+
outcome: "failure",
|
|
278
|
+
severity: "info",
|
|
279
|
+
resourceType: "cf_worker",
|
|
280
|
+
resourceId: supportWorkerName(this.#project, env),
|
|
281
|
+
});
|
|
282
|
+
throw error;
|
|
283
|
+
} finally {
|
|
284
|
+
// The resolved config carries provisioned resource ids and is written inside an installed package.
|
|
285
|
+
// It exists for the length of one deploy and is removed whether that deploy worked or not.
|
|
286
|
+
await unlink(configPath).catch(() => {});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Bring the full-text index in this environment's app database into line with `search.fts`.
|
|
292
|
+
*
|
|
293
|
+
* Deliberately **not** a migration. The index is derived — every row comes from
|
|
294
|
+
* `pithy_support_messages`, and `reindexThread` rebuilds it on demand — so it is a provisioned
|
|
295
|
+
* resource like the bucket and the routing rule, not schema whose loss loses data. It also has to
|
|
296
|
+
* live here for a second reason: composing it conditionally into the migration set meant turning
|
|
297
|
+
* the flag off removed an already-applied migration, which Kysely reads as corruption and which
|
|
298
|
+
* blocked `pithy migrate` for **every** capability sharing that database, not just support.
|
|
299
|
+
*
|
|
300
|
+
* Both statements are `IF [NOT] EXISTS`, so this is safe to re-run — and the current state is read
|
|
301
|
+
* rather than assumed, so the result reports what actually changed instead of what was attempted.
|
|
302
|
+
*/
|
|
303
|
+
async ensureSearchIndex(env: ManagedEnvironment): Promise<{ created: boolean; dropped: boolean }> {
|
|
304
|
+
const { supportDatabase, createSearchIndex, dropSearchIndex, reindexAll, SEARCH_TABLE } = await loadSupportSearch();
|
|
305
|
+
const { appDatabaseId } = await this.#resolveEnv(env);
|
|
306
|
+
const database = this.#cf.d1(appDatabaseId);
|
|
307
|
+
|
|
308
|
+
const listed = await database
|
|
309
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
|
310
|
+
.bind(SEARCH_TABLE)
|
|
311
|
+
.all<{ name: string }>();
|
|
312
|
+
const present = (listed.results ?? []).length > 0;
|
|
313
|
+
const wanted = this.#supportConfig.search.fts;
|
|
314
|
+
|
|
315
|
+
if (wanted === present) return { created: false, dropped: false };
|
|
316
|
+
|
|
317
|
+
const db = supportDatabase(database);
|
|
318
|
+
if (wanted) {
|
|
319
|
+
await createSearchIndex(db);
|
|
320
|
+
// **Backfilled immediately.** An index created over messages that already exist is empty, and
|
|
321
|
+
// because the table now exists the runtime's `LIKE` fallback stops firing — so the inbox would
|
|
322
|
+
// answer "no matches" for a term plainly in the body. Creating without populating turns the
|
|
323
|
+
// feature on and the results off, which is the one direction a filter must never fail in.
|
|
324
|
+
await reindexAll(db);
|
|
325
|
+
} else {
|
|
326
|
+
await dropSearchIndex(db);
|
|
327
|
+
}
|
|
328
|
+
await this.#audit({
|
|
329
|
+
environment: env,
|
|
330
|
+
action: wanted ? "support/search_index_created" : "support/search_index_dropped",
|
|
331
|
+
outcome: "success",
|
|
332
|
+
severity: "info",
|
|
333
|
+
resourceType: "d1_table",
|
|
334
|
+
resourceId: SEARCH_TABLE,
|
|
335
|
+
});
|
|
336
|
+
return { created: wanted, dropped: !wanted };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Create the inbound routing rule that actually delivers the support address to the app worker, when
|
|
341
|
+
* routing was supplied; otherwise skip.
|
|
342
|
+
*
|
|
343
|
+
* The rule name is support's own (`<project>-global-support-inbound`), never `@pithy-sh/email`'s, and it
|
|
344
|
+
* carries the project. Idempotency keys on the name, so a shared one would make whichever capability —
|
|
345
|
+
* or whichever project on the same zone — provisioned second silently believe its rule already existed,
|
|
346
|
+
* and its mail would go to the other one's Worker.
|
|
347
|
+
*/
|
|
348
|
+
async ensureRoutingRule(): Promise<{ created: boolean; skipped: boolean }> {
|
|
349
|
+
if (!this.#routing) return { created: false, skipped: true };
|
|
350
|
+
const { supportRoutingRuleName } = await loadSupport();
|
|
351
|
+
const ruleName = supportRoutingRuleName(this.#project);
|
|
352
|
+
const { created } = await this.#cf.emailRouting().ensureWorkerRoute({
|
|
353
|
+
zoneId: this.#routing.zoneId,
|
|
354
|
+
address: this.#routing.address,
|
|
355
|
+
workerName: this.#routing.appWorkerName,
|
|
356
|
+
ruleName,
|
|
357
|
+
});
|
|
358
|
+
if (created) {
|
|
359
|
+
await this.#audit({
|
|
360
|
+
environment: "global",
|
|
361
|
+
action: "support/routing_rule_created",
|
|
362
|
+
outcome: "success",
|
|
363
|
+
severity: "info",
|
|
364
|
+
resourceType: "cf_email_routing_rule",
|
|
365
|
+
resourceId: this.#routing.address,
|
|
366
|
+
metadata: {
|
|
367
|
+
zoneId: this.#routing.zoneId,
|
|
368
|
+
address: this.#routing.address,
|
|
369
|
+
workerName: this.#routing.appWorkerName,
|
|
370
|
+
ruleName,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return { created, skipped: false };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The directory of the prebuilt support worker inside the installed `@pithy-sh/support` package (holds wrangler.jsonc). */
|
|
379
|
+
function supportWorkerDir(): string {
|
|
380
|
+
try {
|
|
381
|
+
return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/support/src/workflows/worker")));
|
|
382
|
+
} catch (error) {
|
|
383
|
+
throw capabilityLoadError("support", "@pithy-sh/support/src/workflows/worker", error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface CloudflareSupportDeprovisionerOptions {
|
|
388
|
+
cf: CloudflareClients;
|
|
389
|
+
/** The project name, from `requireProjectName` — teardown finds resources by no other key. */
|
|
390
|
+
project: string;
|
|
391
|
+
/**
|
|
392
|
+
* The zone the inbound rule lives on. Needed to remove it, and there is no honest way to derive it: a
|
|
393
|
+
* rule is addressed through its zone, and sweeping every zone on the account looking for a name is a
|
|
394
|
+
* search this command should not be making across an adopter's domains. Omitted → the rule is left, and
|
|
395
|
+
* the command says so rather than reporting mail stopped when it has not.
|
|
396
|
+
*/
|
|
397
|
+
routingZoneId?: string;
|
|
398
|
+
/**
|
|
399
|
+
* The R2 S3 key pair, needed only when the bucket comes down. Emptying a bucket is an S3-protocol
|
|
400
|
+
* operation and R2 refuses to delete a non-empty one, so a bucket teardown cannot run on the API token
|
|
401
|
+
* alone. Omitted when `deleteStorage` is off and no bucket is touched.
|
|
402
|
+
*/
|
|
403
|
+
r2Credentials?: R2Credentials;
|
|
404
|
+
/**
|
|
405
|
+
* The account this teardown deletes from, and what vouches for it (#378).
|
|
406
|
+
*
|
|
407
|
+
* Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
|
|
408
|
+
* miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
|
|
409
|
+
* exits 0. A caller that has not decided which account it is tearing down cannot compile.
|
|
410
|
+
*/
|
|
411
|
+
account: ConfirmedAccount;
|
|
412
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
413
|
+
audit?: CliAuditEmit;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The live {@link SupportDeprovisioner} — removes the inbound rule and each environment's classification
|
|
418
|
+
* worker and, when asked, the bucket with every attachment and raw message in it. Every step is guarded so
|
|
419
|
+
* a missing resource is a no-op: teardown is idempotent.
|
|
420
|
+
*/
|
|
421
|
+
export class CloudflareSupportDeprovisioner implements SupportDeprovisioner {
|
|
422
|
+
readonly #cf: CloudflareClients;
|
|
423
|
+
readonly #project: string;
|
|
424
|
+
readonly #routingZoneId: string | undefined;
|
|
425
|
+
readonly #r2Credentials: R2Credentials | undefined;
|
|
426
|
+
readonly #account: ConfirmedAccount;
|
|
427
|
+
readonly #audit: CliAuditEmit;
|
|
428
|
+
|
|
429
|
+
constructor(options: CloudflareSupportDeprovisionerOptions) {
|
|
430
|
+
this.#cf = options.cf;
|
|
431
|
+
this.#project = options.project;
|
|
432
|
+
this.#routingZoneId = options.routingZoneId;
|
|
433
|
+
this.#r2Credentials = options.r2Credentials;
|
|
434
|
+
this.#account = options.account;
|
|
435
|
+
this.#audit = options.audit ?? (async () => {});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Delete the env's classification worker if it is deployed. */
|
|
439
|
+
async deleteWorker(env: ManagedEnvironment): Promise<void> {
|
|
440
|
+
const { supportWorkerName } = await loadSupport();
|
|
441
|
+
const name = supportWorkerName(this.#project, env);
|
|
442
|
+
if (
|
|
443
|
+
await findOnConfirmedAccount({
|
|
444
|
+
...this.#account,
|
|
445
|
+
what: `the ${name} Worker`,
|
|
446
|
+
find: () => this.#cf.workers().getWorker(name),
|
|
447
|
+
})
|
|
448
|
+
) {
|
|
449
|
+
await this.#cf.workers().deleteWorker(name);
|
|
450
|
+
await this.#audit({
|
|
451
|
+
environment: env,
|
|
452
|
+
action: "support/worker_removed",
|
|
453
|
+
outcome: "success",
|
|
454
|
+
severity: "warning",
|
|
455
|
+
resourceType: "cf_worker",
|
|
456
|
+
resourceId: name,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Remove the inbound rule, so mail stops being delivered here. Matched on support's own rule name, so a
|
|
463
|
+
* teardown can never take the bounce handler's rule — or an operator's hand-written one — with it.
|
|
464
|
+
*/
|
|
465
|
+
async removeRoutingRule(): Promise<{ removed: boolean }> {
|
|
466
|
+
if (!this.#routingZoneId) return { removed: false };
|
|
467
|
+
const { supportRoutingRuleName } = await loadSupport();
|
|
468
|
+
const ruleName = supportRoutingRuleName(this.#project);
|
|
469
|
+
const { removed } = await this.#cf.emailRouting().removeWorkerRoute({
|
|
470
|
+
zoneId: this.#routingZoneId,
|
|
471
|
+
ruleName,
|
|
472
|
+
});
|
|
473
|
+
if (removed) {
|
|
474
|
+
await this.#audit({
|
|
475
|
+
environment: "global",
|
|
476
|
+
action: "support/routing_rule_removed",
|
|
477
|
+
outcome: "success",
|
|
478
|
+
severity: "warning",
|
|
479
|
+
resourceType: "cf_email_routing_rule",
|
|
480
|
+
resourceId: ruleName,
|
|
481
|
+
metadata: { zoneId: this.#routingZoneId, ruleName },
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return { removed };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Delete the bucket and every object in it, if it exists — every attachment and every raw message an
|
|
489
|
+
* adopter's customers ever sent, which is why nothing calls this without an explicit `--storage`. The
|
|
490
|
+
* drain is not optional: R2 refuses to delete a bucket still holding an object or a dangling multipart
|
|
491
|
+
* upload. What went is audited, not just that it went.
|
|
492
|
+
*/
|
|
493
|
+
async deleteBucket(): Promise<void> {
|
|
494
|
+
const name = supportBucketName(this.#project);
|
|
495
|
+
const teardown = await deleteR2BucketWithContents({
|
|
496
|
+
cf: this.#cf,
|
|
497
|
+
credentials: this.#r2Credentials,
|
|
498
|
+
bucketName: name,
|
|
499
|
+
});
|
|
500
|
+
if (!teardown.deleted) return;
|
|
501
|
+
await this.#audit({
|
|
502
|
+
environment: "global",
|
|
503
|
+
action: "support/bucket_deleted",
|
|
504
|
+
outcome: "success",
|
|
505
|
+
severity: "warning",
|
|
506
|
+
resourceType: "cf_r2_bucket",
|
|
507
|
+
resourceId: name,
|
|
508
|
+
metadata: {
|
|
509
|
+
name,
|
|
510
|
+
objectsDeleted: teardown.objectsDeleted,
|
|
511
|
+
uploadsAborted: teardown.uploadsAborted,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { capabilityLoadError } from "./loadFailure";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `@pithy-sh/testers` is an **optional** capability, so the CLI must not hard-depend on it. Types come
|
|
8
|
+
* in through type-only imports (erased at build), and every runtime value comes through
|
|
9
|
+
* {@link loadTesters} — a guarded dynamic import that turns "the package isn't installed" into an
|
|
10
|
+
* actionable error rather than an unresolved-module crash.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** The testers runtime surface the CLI commands need, from the project's own install. */
|
|
14
|
+
type TestersCapabilityModule = typeof import("@pithy-sh/testers/src/capability");
|
|
15
|
+
type TestersTablesModule = typeof import("@pithy-sh/testers/src/data/tables");
|
|
16
|
+
type TestersReadModule = typeof import("@pithy-sh/testers/src/roster/read");
|
|
17
|
+
type TestersWriteModule = typeof import("@pithy-sh/testers/src/roster/write");
|
|
18
|
+
type TestersDailyModule = typeof import("@pithy-sh/testers/src/workflows/daily");
|
|
19
|
+
type TestersViewModule = typeof import("@pithy-sh/testers/src/http/view");
|
|
20
|
+
type TestersConfigModule = typeof import("@pithy-sh/testers/src/config/config");
|
|
21
|
+
|
|
22
|
+
/** Everything `pithy testers` loads out of the optional package, in one guarded import. */
|
|
23
|
+
export type TestersModule = TestersCapabilityModule &
|
|
24
|
+
TestersTablesModule &
|
|
25
|
+
TestersReadModule &
|
|
26
|
+
TestersWriteModule &
|
|
27
|
+
TestersDailyModule &
|
|
28
|
+
TestersViewModule &
|
|
29
|
+
TestersConfigModule;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load `@pithy-sh/testers` from the project's own install.
|
|
33
|
+
*
|
|
34
|
+
* The one place the optional dependency is resolved, so a project that has not added testers gets one
|
|
35
|
+
* clear instruction instead of a module error from whichever call site happened to run first.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadTesters(): Promise<TestersModule> {
|
|
38
|
+
try {
|
|
39
|
+
const [capability, tables, read, write, daily, view, config] = await Promise.all([
|
|
40
|
+
import("@pithy-sh/testers/src/capability"),
|
|
41
|
+
import("@pithy-sh/testers/src/data/tables"),
|
|
42
|
+
import("@pithy-sh/testers/src/roster/read"),
|
|
43
|
+
import("@pithy-sh/testers/src/roster/write"),
|
|
44
|
+
import("@pithy-sh/testers/src/workflows/daily"),
|
|
45
|
+
import("@pithy-sh/testers/src/http/view"),
|
|
46
|
+
import("@pithy-sh/testers/src/config/config"),
|
|
47
|
+
]);
|
|
48
|
+
return { ...capability, ...tables, ...read, ...write, ...daily, ...view, ...config };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw capabilityLoadError("testers", "@pithy-sh/testers", error);
|
|
51
|
+
}
|
|
52
|
+
}
|