@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,168 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CapabilityManifest } from "@pithy-sh/core/src/capability/manifest";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **A capability's prerequisites are declared in its manifest, and every command reads them from there.**
|
|
10
|
+
*
|
|
11
|
+
* `peerCapabilities` has been in `pithy.manifest.json` since the contract landed, and `createBackend`
|
|
12
|
+
* has always refused to assemble without them — `Capability "auth" requires the "secrets" capability,
|
|
13
|
+
* which is not composed.` What was missing was anything between the two: `pithy add auth` wrote the
|
|
14
|
+
* registration, said `Done.`, and left a Worker that could not start (#273). `pithy doctor` said the
|
|
15
|
+
* project was healthy, because it checked bindings, config keys and migrations, and nobody had asked it
|
|
16
|
+
* this question.
|
|
17
|
+
*
|
|
18
|
+
* So this module is the one place that answers it, for both commands. `pithy add` resolves the closure
|
|
19
|
+
* before it wires anything; `pithy doctor` reports a composed capability whose peer is absent and fails
|
|
20
|
+
* its exit. Neither hand-lists a capability's prerequisites, because hand-listing them is how the next
|
|
21
|
+
* capability's get missed — `payments`, `support`, `storage`, `media` and `turnstile` all declare
|
|
22
|
+
* `secrets`, and `testers` declares `email`.
|
|
23
|
+
*
|
|
24
|
+
* The manifest, and not the composed instance's `dependsOn`: the two mirror each other by test in each
|
|
25
|
+
* capability's package, and the manifest is the half that is readable before a capability is composed —
|
|
26
|
+
* which is exactly when `pithy add` has to decide.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A composed capability whose declared prerequisite is not composed beside it. */
|
|
30
|
+
export const MissingPrerequisite = z
|
|
31
|
+
.object({
|
|
32
|
+
capability: z.string().describe("The composed capability that declares the prerequisite, by manifest name."),
|
|
33
|
+
requires: z
|
|
34
|
+
.string()
|
|
35
|
+
.describe(
|
|
36
|
+
"The capability it declares as a peer and this Worker does not compose. Boot refuses on exactly this pair.",
|
|
37
|
+
),
|
|
38
|
+
})
|
|
39
|
+
.describe("A composed capability whose manifest declares a peer the Worker's config does not register.");
|
|
40
|
+
export type MissingPrerequisite = z.infer<typeof MissingPrerequisite>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a Worker's `pithy.config.ts` **registers** a capability — the call, not the import.
|
|
44
|
+
*
|
|
45
|
+
* The call is what composes, so the call is what is asked about. A config that imports `email` and never
|
|
46
|
+
* writes `email()` composes nothing, and `createBackend` agrees. Anchored to the start of a line and to
|
|
47
|
+
* the whole name, so `myauth(` is not `auth(` — the same rule `pithy add`'s idempotency check uses, and
|
|
48
|
+
* it reads the source rather than importing it: the config this is asked about is frequently one that
|
|
49
|
+
* cannot boot.
|
|
50
|
+
*/
|
|
51
|
+
export function isRegistered(source: string, name: string): boolean {
|
|
52
|
+
return new RegExp(`^[ \\t]*${escapeRegExp(name)}[ \\t]*\\(`, "m").test(source);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Escape a capability name for use inside a `RegExp` (names are bare identifiers, but be safe). */
|
|
56
|
+
function escapeRegExp(text: string): string {
|
|
57
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What {@link prerequisiteClosure} needs: the capability being added, what is already composed, and the manifests. */
|
|
61
|
+
export interface PrerequisiteClosureOptions {
|
|
62
|
+
/** The manifest of the capability being added — the root of the walk. */
|
|
63
|
+
manifest: CapabilityManifest;
|
|
64
|
+
/** The capability names this Worker already composes. A peer in here is satisfied and never proposed. */
|
|
65
|
+
composed: ReadonlySet<string>;
|
|
66
|
+
/**
|
|
67
|
+
* A peer's manifest, or `undefined` when its package is not installed yet.
|
|
68
|
+
*
|
|
69
|
+
* Undefined is a real answer and not a failure: a peer nobody has installed is still missing, and
|
|
70
|
+
* naming it beats silence. It is walked no deeper, because there is nothing there to read — the add
|
|
71
|
+
* that composes it resolves its own peers once its package is on disk.
|
|
72
|
+
*/
|
|
73
|
+
manifestFor: (name: string) => CapabilityManifest | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The prerequisites one capability needs and this Worker lacks, **deepest first**.
|
|
78
|
+
*
|
|
79
|
+
* Order is the point, which is why this is a graph walk and not a filter over `peerCapabilities`.
|
|
80
|
+
* `auth` declares `secrets` and `email`; `email` declares `secrets`; and `pithy add email` into a
|
|
81
|
+
* Worker with no secrets composed is the same defect one capability along. Post-order DFS puts a
|
|
82
|
+
* capability after everything it reads, whatever order its manifest happens to list them in.
|
|
83
|
+
*
|
|
84
|
+
* Cycles terminate. Nothing in the kit declares one, and a manifest is somebody else's file.
|
|
85
|
+
*/
|
|
86
|
+
export function prerequisiteClosure(options: PrerequisiteClosureOptions): string[] {
|
|
87
|
+
const { composed, manifestFor } = options;
|
|
88
|
+
const order: string[] = [];
|
|
89
|
+
const resolved = new Set<string>();
|
|
90
|
+
// Everything currently on the walk, root included — a peer already being resolved above is what a
|
|
91
|
+
// cycle looks like from in here, and re-entering it is what would not terminate.
|
|
92
|
+
const walking = new Set<string>([options.manifest.name]);
|
|
93
|
+
|
|
94
|
+
const visit = (manifest: CapabilityManifest): void => {
|
|
95
|
+
for (const peer of manifest.peerCapabilities) {
|
|
96
|
+
if (composed.has(peer) || resolved.has(peer) || walking.has(peer)) continue;
|
|
97
|
+
walking.add(peer);
|
|
98
|
+
const peerManifest = manifestFor(peer);
|
|
99
|
+
if (peerManifest) visit(peerManifest);
|
|
100
|
+
walking.delete(peer);
|
|
101
|
+
resolved.add(peer);
|
|
102
|
+
order.push(peer);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
visit(options.manifest);
|
|
106
|
+
return order;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every prerequisite a Worker's composed set declares and does not satisfy — `pithy doctor`'s check.
|
|
111
|
+
*
|
|
112
|
+
* Scoped to the composed set on purpose. Manifests resolve once from the project root, so they describe
|
|
113
|
+
* every capability installed *anywhere* in the project; only this Worker's `pithy.config.ts` says what
|
|
114
|
+
* this Worker is made of. An `audit` another Worker composes must not make this one unhealthy.
|
|
115
|
+
*
|
|
116
|
+
* Reported in the order a manifest declares its peers, so the sentence matches the file.
|
|
117
|
+
*/
|
|
118
|
+
export function missingPrerequisites(
|
|
119
|
+
manifests: readonly CapabilityManifest[],
|
|
120
|
+
composed: ReadonlySet<string>,
|
|
121
|
+
): MissingPrerequisite[] {
|
|
122
|
+
const missing: MissingPrerequisite[] = [];
|
|
123
|
+
for (const manifest of manifests) {
|
|
124
|
+
if (!composed.has(manifest.name)) continue;
|
|
125
|
+
for (const peer of manifest.peerCapabilities) {
|
|
126
|
+
if (!composed.has(peer)) missing.push({ capability: manifest.name, requires: peer });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return missing;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `a`, `a and b`, `a, b and c` — for a sentence, never for a command line. */
|
|
133
|
+
function andList(names: readonly string[]): string {
|
|
134
|
+
if (names.length <= 1) return names.join("");
|
|
135
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What {@link prerequisiteRefusal} names: the capability asked for, the Worker, and what it lacks. */
|
|
139
|
+
export interface PrerequisiteRefusalOptions {
|
|
140
|
+
/** The capability the adopter asked for. */
|
|
141
|
+
capability: string;
|
|
142
|
+
/** The Worker it was to be wired into — the one whose config lacks the peers. */
|
|
143
|
+
worker: string;
|
|
144
|
+
/** The missing prerequisites, deepest first, as {@link prerequisiteClosure} ordered them. */
|
|
145
|
+
missing: readonly string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The refusal a run that cannot ask takes.
|
|
150
|
+
*
|
|
151
|
+
* **It names the commands, in dependency order.** A refusal that says only "auth requires secrets" makes
|
|
152
|
+
* the adopter guess the order, and guessing wrong reproduces the defect one capability along. The flag
|
|
153
|
+
* comes first because it is the one-command answer; the manual sequence is there for whoever wants to
|
|
154
|
+
* decide each one, which is the honest half of composing something nobody asked for.
|
|
155
|
+
*/
|
|
156
|
+
export function prerequisiteRefusal(options: PrerequisiteRefusalOptions): ValidationError {
|
|
157
|
+
const { capability, worker, missing } = options;
|
|
158
|
+
const commands = missing.map((name) => `pithy add ${name}`);
|
|
159
|
+
const sequence =
|
|
160
|
+
commands.length > 1
|
|
161
|
+
? `${commands.slice(0, -1).join(", ")}, then ${commands[commands.length - 1]}`
|
|
162
|
+
: commands.join("");
|
|
163
|
+
return new ValidationError({
|
|
164
|
+
message: `${capability} requires ${andList(missing)}, which ${worker} does not compose.`,
|
|
165
|
+
action: `Run pithy add ${capability} --with-prerequisites, or compose ${missing.length === 1 ? "it" : "them"} first: ${sequence}.`,
|
|
166
|
+
detail: `${capability}'s manifest declares peerCapabilities ${JSON.stringify(missing)}; createBackend refuses to assemble without them.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import { R2Credentials } from "@pithy-sh/cloudflare/src/r2/r2Credentials";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The R2 bucket steps `pithy storage` and `pithy media` share: resolving the S3 key pair, and taking a
|
|
10
|
+
* bucket down with everything in it.
|
|
11
|
+
*
|
|
12
|
+
* Both capabilities provision a per-environment bucket and both tear one down, so the sequence lives
|
|
13
|
+
* here rather than twice. It sits in the CLI provisioner layer, not in either package: the packages own
|
|
14
|
+
* the *orchestration* (which environments, in what order) behind a seam they unit-test with a fake, and
|
|
15
|
+
* the live Cloudflare calls are deliberately on this side of that seam.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the R2 S3 key pair: the explicit flags first, then `R2_CREDENTIALS` in `.dev.vars`. Both legs
|
|
20
|
+
* validate through the canonical {@link R2Credentials} schema, so a half-supplied or malformed pair fails
|
|
21
|
+
* here rather than at the Worker's first presign — or, on teardown, half way through deleting buckets.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveR2Credentials(
|
|
24
|
+
accessKeyId: string | undefined,
|
|
25
|
+
secretAccessKey: string | undefined,
|
|
26
|
+
raw: string | undefined,
|
|
27
|
+
): R2Credentials {
|
|
28
|
+
if (accessKeyId || secretAccessKey) {
|
|
29
|
+
const parsed = R2Credentials.safeParse({ accessKeyId, secretAccessKey });
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
throw new ValidationError({
|
|
32
|
+
message: "The R2 access-key pair is incomplete.",
|
|
33
|
+
action: "Pass both --r2-access-key-id and --r2-secret-access-key.",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return parsed.data;
|
|
37
|
+
}
|
|
38
|
+
if (!raw) {
|
|
39
|
+
throw new ValidationError({
|
|
40
|
+
message: "No R2 S3 credentials were supplied.",
|
|
41
|
+
action:
|
|
42
|
+
"Pass --r2-access-key-id and --r2-secret-access-key, or set R2_CREDENTIALS in .dev.vars. Create the pair under R2 → Manage API tokens; Cloudflare has no API for minting one.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
let decoded: unknown;
|
|
46
|
+
try {
|
|
47
|
+
decoded = JSON.parse(raw);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new ValidationError({
|
|
50
|
+
message: "R2_CREDENTIALS is not valid JSON.",
|
|
51
|
+
action: 'Set R2_CREDENTIALS to {"accessKeyId":"…","secretAccessKey":"…"}.',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const parsed = R2Credentials.safeParse(decoded);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
throw new ValidationError({
|
|
57
|
+
message: "R2_CREDENTIALS is not a valid access-key/secret-key pair.",
|
|
58
|
+
action: 'Set R2_CREDENTIALS to {"accessKeyId":"…","secretAccessKey":"…"}.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return parsed.data;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a bucket teardown did, so the caller can audit it and report it. */
|
|
65
|
+
export interface R2BucketTeardown {
|
|
66
|
+
/** Whether a bucket was there to delete. `false` means teardown found nothing and did nothing. */
|
|
67
|
+
deleted: boolean;
|
|
68
|
+
/** How many stored objects the drain removed before the bucket came down. */
|
|
69
|
+
objectsDeleted: number;
|
|
70
|
+
/** How many abandoned multipart uploads the drain aborted. */
|
|
71
|
+
uploadsAborted: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What deleting one bucket needs: the clients, the S3 key pair the drain runs on, and the name. */
|
|
75
|
+
export interface DeleteR2BucketOptions {
|
|
76
|
+
/** The configured Cloudflare clients — the control plane for the delete, the object plane for the drain. */
|
|
77
|
+
cf: CloudflareClients;
|
|
78
|
+
/**
|
|
79
|
+
* The R2 S3 key pair. Optional because a deprovision that does not touch storage never needs one; a
|
|
80
|
+
* teardown that reaches a real bucket without it fails with an actionable error instead of a SigV4 fault.
|
|
81
|
+
*/
|
|
82
|
+
credentials?: R2Credentials;
|
|
83
|
+
/** The bucket to remove, by name — R2's only address for one. */
|
|
84
|
+
bucketName: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delete a bucket **and everything in it**, or do nothing if it is not there.
|
|
89
|
+
*
|
|
90
|
+
* Drain first, then delete. R2 rejects the delete of a non-empty bucket, so a teardown that only called
|
|
91
|
+
* the control plane worked on an untouched bucket and failed on every bucket anyone had actually used —
|
|
92
|
+
* the one case it exists for. Emptying is an S3-protocol operation the CF API cannot express, which is
|
|
93
|
+
* why this needs the key pair and the plain `deleteBucket` above it does not.
|
|
94
|
+
*
|
|
95
|
+
* Idempotent end to end: a missing bucket, an empty one, and a retried run all land the same way.
|
|
96
|
+
*/
|
|
97
|
+
export async function deleteR2BucketWithContents(options: DeleteR2BucketOptions): Promise<R2BucketTeardown> {
|
|
98
|
+
const existing = await options.cf.r2Provisioner().findBucketByName(options.bucketName);
|
|
99
|
+
if (!existing) return { deleted: false, objectsDeleted: 0, uploadsAborted: 0 };
|
|
100
|
+
|
|
101
|
+
const credentials = options.credentials;
|
|
102
|
+
if (!credentials) {
|
|
103
|
+
throw new ValidationError({
|
|
104
|
+
message: `The R2 access-key pair is needed to delete the ${existing.name} bucket.`,
|
|
105
|
+
action: "Pass --r2-access-key-id and --r2-secret-access-key, or set R2_CREDENTIALS in .dev.vars.",
|
|
106
|
+
detail: "R2 refuses to delete a non-empty bucket, and emptying one is an S3-protocol operation.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const drain = await options.cf.r2({ ...credentials, bucketName: existing.name }).emptyBucket();
|
|
111
|
+
await options.cf.r2Provisioner().deleteBucket(existing.name);
|
|
112
|
+
return { deleted: true, ...drain };
|
|
113
|
+
}
|