@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,414 @@
|
|
|
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 { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
11
|
+
import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
12
|
+
import { dispatchSecretWrite, type SecretDispatcher } from "@pithy-sh/secrets/src/cli/dispatch";
|
|
13
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
14
|
+
import { parse } from "comment-json";
|
|
15
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
16
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
17
|
+
import { runWrangler } from "../project/wrangler";
|
|
18
|
+
import { capabilityLoadError } from "./loadFailure";
|
|
19
|
+
import { deleteR2BucketWithContents } from "./r2Bucket";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The live storage provisioner — the Cloudflare + wrangler implementation behind `@pithy-sh/storage`'s
|
|
23
|
+
* `StorageProvisioner` seam. Control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: the CF
|
|
24
|
+
* API only via that client) and are each idempotent; the worker deploy shells out to wrangler with the
|
|
25
|
+
* bootstrap token.
|
|
26
|
+
*
|
|
27
|
+
* `@pithy-sh/storage` is an **optional** capability, so the CLI must not hard-depend on it. Types come
|
|
28
|
+
* in through type-only imports (erased at build), and every runtime value comes through
|
|
29
|
+
* {@link loadStorage} — a guarded dynamic import that turns "the package isn't installed" into an
|
|
30
|
+
* actionable error rather than an unresolved-module crash.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The storage runtime surface provisioning needs, loaded from the project's own install. */
|
|
34
|
+
type StorageProvisionModule = typeof import("@pithy-sh/storage/src/provision/provisionStorage");
|
|
35
|
+
type StorageResolveModule = typeof import("@pithy-sh/storage/src/provision/resolveStorageConfig");
|
|
36
|
+
type StorageRegistryModule = typeof import("@pithy-sh/storage/src/secret/registry");
|
|
37
|
+
type StorageCapabilityModule = typeof import("@pithy-sh/storage/src/capability");
|
|
38
|
+
type StorageSpecsModule = typeof import("@pithy-sh/storage/src/workflows/specs");
|
|
39
|
+
|
|
40
|
+
/** The provisioner seams, referenced by type only so the CLI gains no dependency on the package. */
|
|
41
|
+
type StorageProvisioner = import("@pithy-sh/storage/src/provision/provisionStorage").StorageProvisioner;
|
|
42
|
+
type StorageDeprovisioner = import("@pithy-sh/storage/src/provision/provisionStorage").StorageDeprovisioner;
|
|
43
|
+
type StorageResources = import("@pithy-sh/storage/src/provision/provisionStorage").StorageResources;
|
|
44
|
+
type StorageConfig = import("@pithy-sh/storage/src/config/config").StorageConfig;
|
|
45
|
+
|
|
46
|
+
/** Everything `pithy storage` loads out of the optional package, in one guarded import. */
|
|
47
|
+
export type StorageModule = StorageProvisionModule &
|
|
48
|
+
StorageResolveModule &
|
|
49
|
+
StorageRegistryModule &
|
|
50
|
+
StorageCapabilityModule &
|
|
51
|
+
StorageSpecsModule;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load `@pithy-sh/storage` from the project's own install. The one place the optional dependency is
|
|
55
|
+
* resolved, so a project that has not added storage gets one clear instruction instead of a module
|
|
56
|
+
* error from whichever call site happened to run first.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadStorage(): Promise<StorageModule> {
|
|
59
|
+
try {
|
|
60
|
+
const [provision, resolve, registry, capability, specs] = await Promise.all([
|
|
61
|
+
import("@pithy-sh/storage/src/provision/provisionStorage"),
|
|
62
|
+
import("@pithy-sh/storage/src/provision/resolveStorageConfig"),
|
|
63
|
+
import("@pithy-sh/storage/src/secret/registry"),
|
|
64
|
+
import("@pithy-sh/storage/src/capability"),
|
|
65
|
+
import("@pithy-sh/storage/src/workflows/specs"),
|
|
66
|
+
]);
|
|
67
|
+
return { ...provision, ...resolve, ...registry, ...capability, ...specs };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw capabilityLoadError("storage", "@pithy-sh/storage", error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The message of an unknown thrown value, for surfacing both legs of a failed upsert. */
|
|
74
|
+
function errorMessage(error: unknown): string {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The routing facts the storage credential secret carries — an encrypted D1 row, per-environment JSON.
|
|
80
|
+
*
|
|
81
|
+
* `d1` is where this value has always physically gone: the write below dispatches to the secrets
|
|
82
|
+
* manager Workflow, which stores it in `pithy_secrets_system_secrets`. No wrangler template ever bound
|
|
83
|
+
* it from the Cloudflare Secrets Store. These facts must agree with `storageSecretsRegistry`'s
|
|
84
|
+
* declaration, because the read seam routes strictly on `backend` — a disagreement sends a deployed
|
|
85
|
+
* read to a binding that does not exist.
|
|
86
|
+
*/
|
|
87
|
+
const SECRET_FACTS = {
|
|
88
|
+
backend: "d1",
|
|
89
|
+
scope: "environment",
|
|
90
|
+
rotatable: false,
|
|
91
|
+
valueType: "json",
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
/** The per-environment resource ids the sweep worker binds, resolved by the caller. */
|
|
95
|
+
export interface StorageEnvResources {
|
|
96
|
+
/** The app database id for this environment — where the `pithy_storage_*` tables live. */
|
|
97
|
+
appDatabaseId: string;
|
|
98
|
+
/** This environment's secrets database id (`<project>-<env>-secrets`) — holds the R2 credentials. */
|
|
99
|
+
secretsDatabaseId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve the per-environment resources for the sweep worker (from the project wrangler + name lookups). */
|
|
103
|
+
export type ResolveStorageEnv = (env: ManagedEnvironment) => Promise<StorageEnvResources>;
|
|
104
|
+
|
|
105
|
+
export interface CloudflareStorageProvisionerOptions {
|
|
106
|
+
cf: CloudflareClients;
|
|
107
|
+
/**
|
|
108
|
+
* The account this provisions into, and what vouches for it (#378).
|
|
109
|
+
*
|
|
110
|
+
* Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
|
|
111
|
+
* already held while a find-or-create read an empty listing as "this account has none" and minted a
|
|
112
|
+
* real resource in whichever account the shell had named. The id is still here — `account.accountId` —
|
|
113
|
+
* and it now travels with the answer to "who says so".
|
|
114
|
+
*/
|
|
115
|
+
account: ConfirmedAccount;
|
|
116
|
+
/**
|
|
117
|
+
* The project name, from `requireProjectName(await loadProject(projectDir))` — never
|
|
118
|
+
* `resolveProjectName`. Every name this provisioner creates, finds, and deletes leads with it, so a
|
|
119
|
+
* guessed value would create a second set of resources beside the real ones and tear down neither.
|
|
120
|
+
*/
|
|
121
|
+
project: string;
|
|
122
|
+
/** The broad bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
|
|
123
|
+
apiToken: string;
|
|
124
|
+
/** The CF Secrets Store id holding the per-env master keys (the sweep worker decrypts its credentials). */
|
|
125
|
+
storeId: string;
|
|
126
|
+
/**
|
|
127
|
+
* The API token written into the storage secret. An R2 S3 access key *is* a CF API token, so whatever
|
|
128
|
+
* minted the key pair already holds this; it is carried so `CloudflareR2Manager` can prove bucket access.
|
|
129
|
+
*/
|
|
130
|
+
storageApiToken: string;
|
|
131
|
+
/**
|
|
132
|
+
* The R2 S3 access-key pair the Worker presigns uploads and downloads with. Supplied, not minted:
|
|
133
|
+
* Cloudflare exposes no API for creating one.
|
|
134
|
+
*/
|
|
135
|
+
r2Credentials: { accessKeyId: string; secretAccessKey: string };
|
|
136
|
+
/** The app's resolved storage config — serialized into the sweep worker's `STORAGE_CONFIG` var. */
|
|
137
|
+
storageConfig: StorageConfig;
|
|
138
|
+
/** The secrets manager dispatcher — writes the credentials into a deployed env's managed store. */
|
|
139
|
+
dispatcher: SecretDispatcher;
|
|
140
|
+
/** Resolve the per-env app DB id and secrets DB id — injected so it is testable + decoupled. */
|
|
141
|
+
resolveEnv: ResolveStorageEnv;
|
|
142
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
143
|
+
audit?: CliAuditEmit;
|
|
144
|
+
/** Every environment this project declares, from the root `pithy.config.ts` — the fan-out set for a `global` secret. */
|
|
145
|
+
environments: DeclaredEnvironments | readonly string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The live {@link StorageProvisioner}. Every step is idempotent, so provisioning is safe to re-run. */
|
|
149
|
+
export class CloudflareStorageProvisioner implements StorageProvisioner {
|
|
150
|
+
readonly #cf: CloudflareClients;
|
|
151
|
+
readonly #account: ConfirmedAccount;
|
|
152
|
+
readonly #project: string;
|
|
153
|
+
readonly #apiToken: string;
|
|
154
|
+
readonly #storeId: string;
|
|
155
|
+
readonly #storageApiToken: string;
|
|
156
|
+
readonly #r2Credentials: { accessKeyId: string; secretAccessKey: string };
|
|
157
|
+
readonly #storageConfig: StorageConfig;
|
|
158
|
+
readonly #dispatcher: SecretDispatcher;
|
|
159
|
+
/**
|
|
160
|
+
* The project's declared environments (#241) — what a `global` secret write fans out across. Carried
|
|
161
|
+
* rather than assumed, so a shared secret reaches every environment the project deploys to.
|
|
162
|
+
*/
|
|
163
|
+
readonly #environments: DeclaredEnvironments | readonly string[];
|
|
164
|
+
readonly #resolveEnv: ResolveStorageEnv;
|
|
165
|
+
readonly #audit: CliAuditEmit;
|
|
166
|
+
|
|
167
|
+
constructor(options: CloudflareStorageProvisionerOptions) {
|
|
168
|
+
this.#cf = options.cf;
|
|
169
|
+
this.#account = options.account;
|
|
170
|
+
this.#project = options.project;
|
|
171
|
+
this.#apiToken = options.apiToken;
|
|
172
|
+
this.#storeId = options.storeId;
|
|
173
|
+
this.#storageApiToken = options.storageApiToken;
|
|
174
|
+
this.#r2Credentials = options.r2Credentials;
|
|
175
|
+
this.#storageConfig = options.storageConfig;
|
|
176
|
+
this.#dispatcher = options.dispatcher;
|
|
177
|
+
this.#environments = options.environments;
|
|
178
|
+
this.#resolveEnv = options.resolveEnv;
|
|
179
|
+
this.#audit = options.audit ?? (async () => {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy a Workflow host. */
|
|
183
|
+
async preflight(): Promise<void> {
|
|
184
|
+
if (!(await this.#cf.workers().accountSubdomain())) {
|
|
185
|
+
throw new ValidationError({
|
|
186
|
+
message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
|
|
187
|
+
action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reuse this environment's R2 bucket if it exists, otherwise create it.
|
|
194
|
+
*
|
|
195
|
+
* Find-then-create is only safe because the name carries the project: R2's namespace is flat and
|
|
196
|
+
* account-wide, so an unscoped name would make "reuse" mean "adopt whatever another Pithy project
|
|
197
|
+
* left here". Cloudflare offers no tags on an R2 bucket, so the name is the whole ownership record —
|
|
198
|
+
* and the audit event writes the project down beside it, which is the only place a human can later
|
|
199
|
+
* read who this bucket belongs to.
|
|
200
|
+
*/
|
|
201
|
+
async ensureBucket(env: ManagedEnvironment): Promise<{ bucketName: string }> {
|
|
202
|
+
const { storageBucketName } = await loadStorage();
|
|
203
|
+
const name = storageBucketName(this.#project, env);
|
|
204
|
+
const existing = await this.#cf.r2Provisioner().findBucketByName(name);
|
|
205
|
+
if (existing) return { bucketName: existing.name };
|
|
206
|
+
const created = await this.#cf.r2Provisioner().createBucket(name);
|
|
207
|
+
await this.#audit({
|
|
208
|
+
environment: env,
|
|
209
|
+
action: "storage/bucket_created",
|
|
210
|
+
outcome: "success",
|
|
211
|
+
severity: "info",
|
|
212
|
+
resourceType: "cf_r2_bucket",
|
|
213
|
+
resourceId: created.name,
|
|
214
|
+
metadata: { name },
|
|
215
|
+
});
|
|
216
|
+
return { bucketName: created.name };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Write the environment's R2 credentials. Upserts, so a re-run heals rather than fails. */
|
|
220
|
+
async writeCredentials(env: ManagedEnvironment, resources: StorageResources): Promise<void> {
|
|
221
|
+
const { STORAGE_R2_SECRET, R2StorageCredentials } = await loadStorage();
|
|
222
|
+
// Validate before dispatching: a malformed secret is otherwise only discovered at the Worker's
|
|
223
|
+
// first presign, long after the operator has walked away from the terminal.
|
|
224
|
+
const value = R2StorageCredentials.parse({
|
|
225
|
+
accessKeyId: this.#r2Credentials.accessKeyId,
|
|
226
|
+
secretAccessKey: this.#r2Credentials.secretAccessKey,
|
|
227
|
+
accountId: this.#account.accountId,
|
|
228
|
+
bucket: resources.bucketName,
|
|
229
|
+
apiToken: this.#storageApiToken,
|
|
230
|
+
});
|
|
231
|
+
// Upsert: create on first provision, update on a re-run (create rejects an existing secret) — so
|
|
232
|
+
// the write is idempotent. If create fails for a real reason the update almost always fails too;
|
|
233
|
+
// surface BOTH causes so the true failure isn't masked by the fallback's error.
|
|
234
|
+
const write = {
|
|
235
|
+
name: STORAGE_R2_SECRET,
|
|
236
|
+
...SECRET_FACTS,
|
|
237
|
+
value: JSON.stringify(value),
|
|
238
|
+
requested: env,
|
|
239
|
+
};
|
|
240
|
+
try {
|
|
241
|
+
await dispatchSecretWrite(this.#dispatcher, { mode: "create", ...write }, this.#environments);
|
|
242
|
+
} catch (createError) {
|
|
243
|
+
try {
|
|
244
|
+
await dispatchSecretWrite(this.#dispatcher, { mode: "update", ...write }, this.#environments);
|
|
245
|
+
} catch (updateError) {
|
|
246
|
+
throw new InternalError(
|
|
247
|
+
{
|
|
248
|
+
message: `Could not write the storage R2 credentials to ${env}.`,
|
|
249
|
+
action: "Check that `pithy secrets provision` has run for this environment, then re-run.",
|
|
250
|
+
detail: `create failed: ${errorMessage(createError)}; update failed: ${errorMessage(updateError)}`,
|
|
251
|
+
},
|
|
252
|
+
{ cause: createError },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await this.#audit({
|
|
257
|
+
environment: env,
|
|
258
|
+
action: "storage/credentials_written",
|
|
259
|
+
outcome: "success",
|
|
260
|
+
severity: "info",
|
|
261
|
+
resourceType: "secret",
|
|
262
|
+
resourceId: STORAGE_R2_SECRET,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Resolve the env's wrangler config from the committed template + provisioned ids, then `wrangler deploy`. */
|
|
267
|
+
async deployWorker(env: ManagedEnvironment, resources: StorageResources): Promise<void> {
|
|
268
|
+
const { storageWorkerName, resolveStorageConfig } = await loadStorage();
|
|
269
|
+
const { appDatabaseId, secretsDatabaseId } = await this.#resolveEnv(env);
|
|
270
|
+
const dir = await storageWorkerDir();
|
|
271
|
+
const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
|
|
272
|
+
const config = resolveStorageConfig(template, {
|
|
273
|
+
project: this.#project,
|
|
274
|
+
env,
|
|
275
|
+
appDatabaseId,
|
|
276
|
+
secretsDatabaseId,
|
|
277
|
+
storeId: this.#storeId,
|
|
278
|
+
resources,
|
|
279
|
+
storageConfig: this.#storageConfig,
|
|
280
|
+
});
|
|
281
|
+
const configPath = join(dir, `.wrangler.${env}.json`);
|
|
282
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
283
|
+
try {
|
|
284
|
+
await runWrangler(["deploy", "--config", configPath], {
|
|
285
|
+
cwd: dir,
|
|
286
|
+
env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#account.accountId },
|
|
287
|
+
});
|
|
288
|
+
await this.#audit({
|
|
289
|
+
environment: env,
|
|
290
|
+
action: "storage/worker_deployed",
|
|
291
|
+
outcome: "success",
|
|
292
|
+
severity: "info",
|
|
293
|
+
resourceType: "cf_worker",
|
|
294
|
+
resourceId: storageWorkerName(this.#project, env),
|
|
295
|
+
});
|
|
296
|
+
} catch (error) {
|
|
297
|
+
await this.#audit({
|
|
298
|
+
environment: env,
|
|
299
|
+
action: "storage/worker_deployed",
|
|
300
|
+
outcome: "failure",
|
|
301
|
+
severity: "info",
|
|
302
|
+
resourceType: "cf_worker",
|
|
303
|
+
resourceId: storageWorkerName(this.#project, env),
|
|
304
|
+
});
|
|
305
|
+
throw error;
|
|
306
|
+
} finally {
|
|
307
|
+
await unlink(configPath).catch(() => {});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The directory of the prebuilt sweep worker inside the installed `@pithy-sh/storage` package. */
|
|
313
|
+
async function storageWorkerDir(): Promise<string> {
|
|
314
|
+
try {
|
|
315
|
+
return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/storage/src/workflows/worker")));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
throw capabilityLoadError("storage", "@pithy-sh/storage/src/workflows/worker", error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface CloudflareStorageDeprovisionerOptions {
|
|
322
|
+
cf: CloudflareClients;
|
|
323
|
+
/** The project name, from `requireProjectName` — teardown finds resources by no other key. */
|
|
324
|
+
project: string;
|
|
325
|
+
/**
|
|
326
|
+
* The R2 S3 key pair, needed only when the buckets come down. Emptying a bucket is an S3-protocol
|
|
327
|
+
* operation and R2 refuses to delete a non-empty one, so a bucket teardown cannot run on the API token
|
|
328
|
+
* alone. Omitted when `deleteStorage` is off and no bucket is touched.
|
|
329
|
+
*/
|
|
330
|
+
r2Credentials?: R2Credentials;
|
|
331
|
+
/**
|
|
332
|
+
* The account this teardown deletes from, and what vouches for it (#378).
|
|
333
|
+
*
|
|
334
|
+
* Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
|
|
335
|
+
* miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
|
|
336
|
+
* exits 0. A caller that has not decided which account it is tearing down cannot compile.
|
|
337
|
+
*/
|
|
338
|
+
account: ConfirmedAccount;
|
|
339
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
340
|
+
audit?: CliAuditEmit;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The live {@link StorageDeprovisioner} — removes each environment's sweep worker and, when asked, the
|
|
345
|
+
* R2 bucket with every object in it. Every step is guarded so a missing resource is a no-op: teardown is
|
|
346
|
+
* idempotent.
|
|
347
|
+
*/
|
|
348
|
+
export class CloudflareStorageDeprovisioner implements StorageDeprovisioner {
|
|
349
|
+
readonly #cf: CloudflareClients;
|
|
350
|
+
readonly #project: string;
|
|
351
|
+
readonly #r2Credentials: R2Credentials | undefined;
|
|
352
|
+
readonly #account: ConfirmedAccount;
|
|
353
|
+
readonly #audit: CliAuditEmit;
|
|
354
|
+
|
|
355
|
+
constructor(options: CloudflareStorageDeprovisionerOptions) {
|
|
356
|
+
this.#cf = options.cf;
|
|
357
|
+
this.#project = options.project;
|
|
358
|
+
this.#r2Credentials = options.r2Credentials;
|
|
359
|
+
this.#account = options.account;
|
|
360
|
+
this.#audit = options.audit ?? (async () => {});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Delete the env's sweep worker if it is deployed. */
|
|
364
|
+
async deleteWorker(env: ManagedEnvironment): Promise<void> {
|
|
365
|
+
const { storageWorkerName } = await loadStorage();
|
|
366
|
+
const name = storageWorkerName(this.#project, env);
|
|
367
|
+
if (
|
|
368
|
+
await findOnConfirmedAccount({
|
|
369
|
+
...this.#account,
|
|
370
|
+
what: `the ${name} Worker`,
|
|
371
|
+
find: () => this.#cf.workers().getWorker(name),
|
|
372
|
+
})
|
|
373
|
+
) {
|
|
374
|
+
await this.#cf.workers().deleteWorker(name);
|
|
375
|
+
await this.#audit({
|
|
376
|
+
environment: env,
|
|
377
|
+
action: "storage/worker_deleted",
|
|
378
|
+
outcome: "success",
|
|
379
|
+
severity: "warning",
|
|
380
|
+
resourceType: "cf_worker",
|
|
381
|
+
resourceId: name,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Delete this environment's R2 bucket and every object in it, if it exists — destructive, and only on
|
|
388
|
+
* an explicit storage teardown. The drain is not optional: R2 refuses to delete a bucket that still
|
|
389
|
+
* holds an object or a dangling multipart upload. What went is audited, not just that the bucket did.
|
|
390
|
+
*/
|
|
391
|
+
async deleteBucket(env: ManagedEnvironment): Promise<void> {
|
|
392
|
+
const { storageBucketName } = await loadStorage();
|
|
393
|
+
const name = storageBucketName(this.#project, env);
|
|
394
|
+
const teardown = await deleteR2BucketWithContents({
|
|
395
|
+
cf: this.#cf,
|
|
396
|
+
credentials: this.#r2Credentials,
|
|
397
|
+
bucketName: name,
|
|
398
|
+
});
|
|
399
|
+
if (!teardown.deleted) return;
|
|
400
|
+
await this.#audit({
|
|
401
|
+
environment: env,
|
|
402
|
+
action: "storage/bucket_deleted",
|
|
403
|
+
outcome: "success",
|
|
404
|
+
severity: "warning",
|
|
405
|
+
resourceType: "cf_r2_bucket",
|
|
406
|
+
resourceId: name,
|
|
407
|
+
metadata: {
|
|
408
|
+
name,
|
|
409
|
+
objectsDeleted: teardown.objectsDeleted,
|
|
410
|
+
uploadsAborted: teardown.uploadsAborted,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|