@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,364 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import { isPermissionKey, PERMISSION_GROUPS, type PermissionKey } from "@pithy-sh/cloudflare/src/tokens/permissions";
|
|
6
|
+
import {
|
|
7
|
+
resolveTokenProfiles,
|
|
8
|
+
TOKEN_STORES,
|
|
9
|
+
type TokenProfile,
|
|
10
|
+
type TokenStore,
|
|
11
|
+
} from "@pithy-sh/cloudflare/src/tokens/profiles";
|
|
12
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
13
|
+
import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
14
|
+
import { defineCommand } from "citty";
|
|
15
|
+
import { createCliAudit } from "../audit/cliAudit";
|
|
16
|
+
import { resolveSecretRegistry } from "../capabilities/secrets";
|
|
17
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
18
|
+
import { loadProject, loadProjectCloudflare, requireProjectName } from "../project/config";
|
|
19
|
+
import { ENV_ARG, requireEnvironment } from "../project/environment";
|
|
20
|
+
import {
|
|
21
|
+
type CapabilitySet,
|
|
22
|
+
capabilitySetOf,
|
|
23
|
+
isUnknown,
|
|
24
|
+
projectCapabilities,
|
|
25
|
+
type ResolvedWorker,
|
|
26
|
+
resolveWorkerSet,
|
|
27
|
+
type WorkerSet,
|
|
28
|
+
} from "../project/workerScope";
|
|
29
|
+
import { formatDone, formatJsonLine, formatList, withErrorReporting } from "../terminal/output";
|
|
30
|
+
import { tokenOverrideResolver } from "../tokens/config";
|
|
31
|
+
import {
|
|
32
|
+
listProfileTokens,
|
|
33
|
+
mintProfileToken,
|
|
34
|
+
revokeProfileToken,
|
|
35
|
+
rotateProfileToken,
|
|
36
|
+
type TokenAudit,
|
|
37
|
+
type TokenEngine,
|
|
38
|
+
type TokenResult,
|
|
39
|
+
} from "../tokens/engine";
|
|
40
|
+
|
|
41
|
+
// `resolveAppDatabaseId` used to be this file's own copy of the app-database lookup, before
|
|
42
|
+
// `createCliAudit` centralized it. Re-exported under its original name — same behavior, same
|
|
43
|
+
// signature — so `token.test.ts`'s direct tests of it keep passing unchanged.
|
|
44
|
+
export { resolveAuditDatabaseId as resolveAppDatabaseId } from "../audit/cliAudit";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The CF credentials the token engine needs, for the account the project belongs to.
|
|
48
|
+
*
|
|
49
|
+
* **The account is a parameter, so this cannot run before the project is loaded.** `pithy token mint`
|
|
50
|
+
* writes real credentials into a real account; resolving them from a file the project did not select
|
|
51
|
+
* would mint another company's token under this project's name (#206).
|
|
52
|
+
*/
|
|
53
|
+
function loadCreds(account: CloudflareAccountSelection | null): {
|
|
54
|
+
accountId: string;
|
|
55
|
+
apiToken: string;
|
|
56
|
+
storeId: string;
|
|
57
|
+
} {
|
|
58
|
+
const vars = cloudflareEnv({ account });
|
|
59
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
60
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
61
|
+
if (!accountId || !apiToken) {
|
|
62
|
+
throw new ValidationError({
|
|
63
|
+
message: "Cloudflare credentials are missing.",
|
|
64
|
+
action: "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (the bootstrap token) to mint tokens.",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { accountId, apiToken, storeId: vars.SECRETS_STORE_ID ?? "" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect every `--permission` from the raw argv. citty keeps only the last occurrence of a repeated
|
|
72
|
+
* string flag, so a multi-permission run would otherwise silently drop all but the last — the same
|
|
73
|
+
* reason `--set` is read from rawArgs. Handles both `--permission key` and `--permission=key`.
|
|
74
|
+
*/
|
|
75
|
+
export function collectPermissionFlags(rawArgs: string[]): string[] {
|
|
76
|
+
const found: string[] = [];
|
|
77
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
78
|
+
const arg = rawArgs[i];
|
|
79
|
+
if (arg === "--permission") {
|
|
80
|
+
const value = rawArgs[i + 1];
|
|
81
|
+
if (value !== undefined) {
|
|
82
|
+
found.push(value);
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
} else if (arg?.startsWith("--permission=")) {
|
|
86
|
+
found.push(arg.slice("--permission=".length));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Validate collected `--permission` keys, or `undefined` when none were given. */
|
|
93
|
+
export function parsePermissions(value: string | string[] | undefined): PermissionKey[] | undefined {
|
|
94
|
+
const keys = value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
95
|
+
if (keys.length === 0) return undefined;
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
if (!isPermissionKey(key)) {
|
|
98
|
+
throw new ValidationError({
|
|
99
|
+
message: `Unknown token permission key: ${key}.`,
|
|
100
|
+
action: `Use one of: ${Object.keys(PERMISSION_GROUPS).join(", ")}.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return keys as PermissionKey[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parse a `--store` flag into a validated store, or `undefined` when absent. */
|
|
108
|
+
export function parseStore(value: string | undefined): TokenStore | undefined {
|
|
109
|
+
if (value === undefined) return undefined;
|
|
110
|
+
if (!(TOKEN_STORES as readonly string[]).includes(value)) {
|
|
111
|
+
throw new ValidationError({
|
|
112
|
+
message: `Unknown token store: ${value}.`,
|
|
113
|
+
action: `Use one of: ${TOKEN_STORES.join(", ")}.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return value as TokenStore;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Project a mint/rotate result to the safe fields for output — everything except the secret `value`. */
|
|
120
|
+
export function publicToken(result: TokenResult): {
|
|
121
|
+
profile: string;
|
|
122
|
+
env: string;
|
|
123
|
+
tokenId: string;
|
|
124
|
+
store: TokenStore;
|
|
125
|
+
location: string;
|
|
126
|
+
} {
|
|
127
|
+
return {
|
|
128
|
+
profile: result.profile,
|
|
129
|
+
env: result.env,
|
|
130
|
+
tokenId: result.tokenId,
|
|
131
|
+
store: result.sink.sink,
|
|
132
|
+
location: result.sink.location,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the token engine's audit sink from the shared `createCliAudit` helper — the one place every
|
|
138
|
+
* CLI command resolves whether/where to record. `createCliAudit` already handles every reason there
|
|
139
|
+
* might be nowhere to write (audit not composed, package not installed, no app database for the env)
|
|
140
|
+
* by returning an always-callable no-op, so `TokenAudit` here is just a thin adapter from its event
|
|
141
|
+
* shape to the generic `CliAuditEvent` — never the token value, only its id and where it went.
|
|
142
|
+
*/
|
|
143
|
+
async function buildAudit(
|
|
144
|
+
capabilities: CapabilitySet,
|
|
145
|
+
cf: CloudflareClients,
|
|
146
|
+
projectDir: string,
|
|
147
|
+
env: string,
|
|
148
|
+
apiToken: string,
|
|
149
|
+
): Promise<TokenAudit> {
|
|
150
|
+
// `env` is both the audit destination and the environment acted on for a token operation.
|
|
151
|
+
const emit = await createCliAudit({ projectDir, env, actedOn: env, capabilities, clients: cf, apiToken });
|
|
152
|
+
return async (event) => {
|
|
153
|
+
await emit({
|
|
154
|
+
action: event.action,
|
|
155
|
+
outcome: event.outcome,
|
|
156
|
+
severity: "warning",
|
|
157
|
+
resourceType: "cf_api_token",
|
|
158
|
+
resourceId: event.tokenId ?? `${event.profile}:${event.env}`,
|
|
159
|
+
metadata: { profile: event.profile, store: event.store ?? null },
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Every secret backend declared anywhere in the project, merged by name. The name is the join key, so a
|
|
166
|
+
* Worker that composes `secrets` contributes its registry and one that does not contributes nothing —
|
|
167
|
+
* a project with no secrets capability at all resolves to `{}` rather than failing a mint.
|
|
168
|
+
*/
|
|
169
|
+
function mergedSecretRegistry(workers: readonly ResolvedWorker[]): SecretRegistry {
|
|
170
|
+
const registries: SecretRegistry[] = [];
|
|
171
|
+
for (const worker of workers) {
|
|
172
|
+
try {
|
|
173
|
+
registries.push(resolveSecretRegistry(worker.config));
|
|
174
|
+
} catch {
|
|
175
|
+
// This Worker composes no secrets capability — nothing to contribute.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.assign({}, ...registries) as SecretRegistry;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The profile registry a token operation derives from — **refused when the capability set cannot be read,
|
|
183
|
+
* never derived from an emptied one** (#455).
|
|
184
|
+
*
|
|
185
|
+
* {@link buildEngine} already insists the *root* config is required and never guessed, because `revoke`
|
|
186
|
+
* deletes every account token of the name it computes. The Worker half was best-effort, and that is the
|
|
187
|
+
* same mistake one level down: `resolveTokenProfiles([])` returns only `ci-system` and drops every
|
|
188
|
+
* capability's `ciPermissions`, so `pithy token list <env>` told an operator auditing live credentials that
|
|
189
|
+
* capability-profile tokens do not exist and exited 0, and `pithy token rotate ci-system <env>` minted a
|
|
190
|
+
* replacement carrying only the base permissions before deleting the fully-permissioned one it replaced.
|
|
191
|
+
* CI lost permissions silently, with nothing in the run saying the capability set was unknown.
|
|
192
|
+
*
|
|
193
|
+
* A project with genuinely **no** Workers still resolves normally: `ci-system` is a project-level profile
|
|
194
|
+
* and mints fine before the first `pithy worker add`. See {@link resolveWorkerSet} for the difference.
|
|
195
|
+
*
|
|
196
|
+
* The refusal quotes the set's own diagnosis rather than inventing one, so it names the worker.
|
|
197
|
+
*/
|
|
198
|
+
export function tokenProfiles(workers: WorkerSet): Record<string, TokenProfile> {
|
|
199
|
+
if (!isUnknown(workers)) return resolveTokenProfiles(projectCapabilities(workers));
|
|
200
|
+
throw new ValidationError({
|
|
201
|
+
message: "This project's capability set is unknown, so token permissions cannot be derived.",
|
|
202
|
+
action:
|
|
203
|
+
`${workers.unknown} Fix it — pithy doctor names the fault — and re-run. Token permissions come from ` +
|
|
204
|
+
"the composed capabilities, so minting from a partial set narrows what CI can do.",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Assemble the token engine: the aggregated profiles, the secret-registry backends, and the audit sink. */
|
|
209
|
+
async function buildEngine(projectDir: string, env: string): Promise<TokenEngine> {
|
|
210
|
+
// Identity/policy comes from the root config; what the project is *made of* comes from each Worker's.
|
|
211
|
+
// The root config is required here, not best-effort: every token name and Secrets Store entry starts
|
|
212
|
+
// with the project name, and `revoke` deletes every account token of the name it computes. Guessing
|
|
213
|
+
// it would point that sweep at another project's credentials.
|
|
214
|
+
//
|
|
215
|
+
// Read *before* the credentials, because it is also what says which account they come from.
|
|
216
|
+
const config = await loadProject(projectDir);
|
|
217
|
+
const { accountId, apiToken, storeId } = loadCreds(loadProjectCloudflare(config) ?? null);
|
|
218
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
219
|
+
// Never best-effort. See {@link tokenProfiles}: an emptied capability set is a silently narrowed credential.
|
|
220
|
+
const workerSet = await resolveWorkerSet({ projectDir });
|
|
221
|
+
const workers = isUnknown(workerSet) ? [] : workerSet;
|
|
222
|
+
const registry = mergedSecretRegistry(workers);
|
|
223
|
+
return {
|
|
224
|
+
accountId,
|
|
225
|
+
project: requireProjectName(config),
|
|
226
|
+
projectDir,
|
|
227
|
+
tokens: cf.accountTokens(),
|
|
228
|
+
/*
|
|
229
|
+
**A getter, so the refusal lands on the operations that actually need the registry.**
|
|
230
|
+
|
|
231
|
+
`mint`, `rotate` and `list` each read `profiles` on their first line — the first two to resolve a
|
|
232
|
+
profile's permissions, the third to recover a profile name by reverse lookup — so an unknown
|
|
233
|
+
capability set makes all three wrong, and they refuse here. `revoke` reads it never: it deletes
|
|
234
|
+
every account token of `tokenName(project, env, profile)`, a name composed from the root config
|
|
235
|
+
alone. Refusing it too would take `#454`'s lesson and repeat it — the one command you need in a
|
|
236
|
+
credential leak, unavailable because an unrelated Worker's config is broken on this checkout, with
|
|
237
|
+
no `--local-only` to fall back to.
|
|
238
|
+
*/
|
|
239
|
+
get profiles() {
|
|
240
|
+
return tokenProfiles(workerSet);
|
|
241
|
+
},
|
|
242
|
+
secretBackend: (name) => registry[name]?.backend,
|
|
243
|
+
putSecret: storeId ? (name, value) => cf.secrets(storeId).putSecret(name, value) : undefined,
|
|
244
|
+
// The set itself when it is unknowable, so the emitter says which worker rather than falling silent.
|
|
245
|
+
audit: await buildAudit(capabilitySetOf(workerSet), cf, projectDir, env, apiToken),
|
|
246
|
+
override: tokenOverrideResolver(config),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const profileArg = {
|
|
251
|
+
profile: {
|
|
252
|
+
type: "positional",
|
|
253
|
+
required: true,
|
|
254
|
+
description: "Token profile: ci-system (the CI credential), or a capability's worker-consumer profile",
|
|
255
|
+
},
|
|
256
|
+
} as const;
|
|
257
|
+
const envArgs = { env: ENV_ARG } as const;
|
|
258
|
+
const jsonArg = { json: { type: "boolean", default: false, description: "Machine-readable output" } } as const;
|
|
259
|
+
const overrideArgs = {
|
|
260
|
+
store: { type: "string", description: `Override where the value is written: ${TOKEN_STORES.join(" | ")}` },
|
|
261
|
+
permission: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description: `Override permissions (repeatable): ${Object.keys(PERMISSION_GROUPS).join(", ")}`,
|
|
264
|
+
},
|
|
265
|
+
} as const;
|
|
266
|
+
|
|
267
|
+
const mint = defineCommand({
|
|
268
|
+
meta: { name: "mint", description: "Mint a scoped account token for a profile (rolls to the current scope)" },
|
|
269
|
+
args: { ...profileArg, ...envArgs, ...overrideArgs, ...jsonArg },
|
|
270
|
+
run: ({ args, rawArgs }) =>
|
|
271
|
+
withErrorReporting(args.json, async () => {
|
|
272
|
+
const env = requireEnvironment(args.env);
|
|
273
|
+
const engine = await buildEngine(process.cwd(), env);
|
|
274
|
+
const result = await mintProfileToken(engine, args.profile, env, {
|
|
275
|
+
store: parseStore(args.store),
|
|
276
|
+
permissions: parsePermissions(collectPermissionFlags(rawArgs)),
|
|
277
|
+
});
|
|
278
|
+
if (args.json) {
|
|
279
|
+
process.stdout.write(`${formatJsonLine({ command: "token mint", ...publicToken(result) })}\n`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
process.stdout.write(`${result.profile}: minted → ${result.sink.location}.\n`);
|
|
283
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const list = defineCommand({
|
|
288
|
+
meta: { name: "list", description: "List minted tokens for an environment (ids and profiles, never values)" },
|
|
289
|
+
args: { ...envArgs, ...jsonArg },
|
|
290
|
+
run: ({ args }) =>
|
|
291
|
+
withErrorReporting(args.json, async () => {
|
|
292
|
+
const env = requireEnvironment(args.env);
|
|
293
|
+
const engine = await buildEngine(process.cwd(), env);
|
|
294
|
+
const tokens = await listProfileTokens(engine, env);
|
|
295
|
+
if (args.json) {
|
|
296
|
+
process.stdout.write(`${formatJsonLine({ command: "token list", env, tokens })}\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (tokens.length === 0) {
|
|
300
|
+
process.stdout.write(`No minted tokens for ${env}.\n`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
process.stdout.write(
|
|
304
|
+
`${formatList(tokens.map((token) => ({ name: token.profile, description: `${token.tokenId}${token.status ? ` · ${token.status}` : ""}` })))}\n`,
|
|
305
|
+
);
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const rotate = defineCommand({
|
|
310
|
+
meta: { name: "rotate", description: "Rotate a profile's token (create new, delete old)" },
|
|
311
|
+
args: {
|
|
312
|
+
...profileArg,
|
|
313
|
+
...envArgs,
|
|
314
|
+
...overrideArgs,
|
|
315
|
+
"keep-previous": { type: "boolean", default: false, description: "Keep the old token as a grace window" },
|
|
316
|
+
...jsonArg,
|
|
317
|
+
},
|
|
318
|
+
run: ({ args, rawArgs }) =>
|
|
319
|
+
withErrorReporting(args.json, async () => {
|
|
320
|
+
const env = requireEnvironment(args.env);
|
|
321
|
+
const engine = await buildEngine(process.cwd(), env);
|
|
322
|
+
const result = await rotateProfileToken(engine, args.profile, env, {
|
|
323
|
+
store: parseStore(args.store),
|
|
324
|
+
permissions: parsePermissions(collectPermissionFlags(rawArgs)),
|
|
325
|
+
keepPrevious: args["keep-previous"],
|
|
326
|
+
});
|
|
327
|
+
if (args.json) {
|
|
328
|
+
process.stdout.write(`${formatJsonLine({ command: "token rotate", ...publicToken(result) })}\n`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
process.stdout.write(`${result.profile}: rotated → ${result.sink.location}.\n`);
|
|
332
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const revoke = defineCommand({
|
|
337
|
+
meta: { name: "revoke", description: "Revoke a profile's token for an environment" },
|
|
338
|
+
args: { ...profileArg, ...envArgs, ...jsonArg },
|
|
339
|
+
run: ({ args }) =>
|
|
340
|
+
withErrorReporting(args.json, async () => {
|
|
341
|
+
const env = requireEnvironment(args.env);
|
|
342
|
+
const engine = await buildEngine(process.cwd(), env);
|
|
343
|
+
const result = await revokeProfileToken(engine, args.profile, env);
|
|
344
|
+
if (args.json) {
|
|
345
|
+
// **A count is published as `revokedCount` (#235).** `pithy dashboard revoke-key` emits
|
|
346
|
+
// `revoked` as a `boolean` — did that key come out — and this one is "how many tokens of that
|
|
347
|
+
// name were deleted", where `0` is an ordinary answer. So `revoked` was a yes/no in one command
|
|
348
|
+
// and a number in another, and the number's falsy value is the one that means "nothing was
|
|
349
|
+
// revoked" in both readings while `revoked: 0` and `revoked: false` are not the same claim. The
|
|
350
|
+
// count is the outlier — every other `<verb>ed` key in this CLI is a boolean — so it takes the
|
|
351
|
+
// suffix. `RevokeResult` keeps `revoked`: the published name is decided here.
|
|
352
|
+
const { revoked: revokedCount, ...rest } = result;
|
|
353
|
+
process.stdout.write(`${formatJsonLine({ command: "token revoke", ...rest, revokedCount })}\n`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
process.stdout.write(`${result.profile}: revoked ${result.revoked} token${result.revoked === 1 ? "" : "s"}.\n`);
|
|
357
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
export default defineCommand({
|
|
362
|
+
meta: { name: "token", description: "Mint and manage scoped Cloudflare API tokens" },
|
|
363
|
+
subCommands: { mint, list, rotate, revoke },
|
|
364
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
7
|
+
import { isTurnstileCapability } from "@pithy-sh/turnstile/src/capability";
|
|
8
|
+
import type { TurnstileConfig, TurnstileMode } from "@pithy-sh/turnstile/src/config/config";
|
|
9
|
+
import {
|
|
10
|
+
deprovisionTurnstile,
|
|
11
|
+
enabledModes,
|
|
12
|
+
provisionTurnstile,
|
|
13
|
+
} from "@pithy-sh/turnstile/src/provision/provisionTurnstile";
|
|
14
|
+
import { defineCommand } from "citty";
|
|
15
|
+
import { createProjectCliAudit } from "../audit/cliAudit";
|
|
16
|
+
import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
|
|
17
|
+
import { CloudflareTurnstileDeprovisioner, CloudflareTurnstileProvisioner } from "../capabilities/turnstileProvisioner";
|
|
18
|
+
import type { ConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
19
|
+
import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
20
|
+
import {
|
|
21
|
+
loadProject,
|
|
22
|
+
loadProjectEnvironments,
|
|
23
|
+
loadWorkerConfig,
|
|
24
|
+
loadWorkerDomains,
|
|
25
|
+
projectCloudflareAccount,
|
|
26
|
+
requireProjectName,
|
|
27
|
+
} from "../project/config";
|
|
28
|
+
import { type AddressStanza, resolveWorkerAddress } from "../project/workerAddress";
|
|
29
|
+
import { projectCapabilities, type ResolvedWorker, resolveSingleWorker, resolveWorkers } from "../project/workerScope";
|
|
30
|
+
import { readWranglerConfig } from "../project/wrangler";
|
|
31
|
+
import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The audit emitter for a turnstile command. Provisioning spans every managed environment at once, so
|
|
35
|
+
* there is no single target env to key the audit database on — `"dev"` is the fallback (mirrors
|
|
36
|
+
* `pithy feature`'s convention for env-spanning commands). A no-op when creds or the audit capability
|
|
37
|
+
* aren't there.
|
|
38
|
+
*/
|
|
39
|
+
async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
|
|
40
|
+
// `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
|
|
41
|
+
// single value is true for the run; each event states the environment it acted on.
|
|
42
|
+
return createProjectCliAudit({ projectDir, accountId, apiToken });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load the turnstile capability's resolved config. Capabilities live in each Worker's
|
|
47
|
+
* `apps/<name>/pithy.config.ts`; the widget set is one project-wide decision, so the first Worker composing
|
|
48
|
+
* `turnstile` provides it.
|
|
49
|
+
*/
|
|
50
|
+
async function loadTurnstileConfig(projectDir: string): Promise<TurnstileConfig> {
|
|
51
|
+
const capabilities = await resolveWorkers({ projectDir }).then(projectCapabilities);
|
|
52
|
+
const cap = capabilities.find(isTurnstileCapability);
|
|
53
|
+
if (!cap) {
|
|
54
|
+
throw new ValidationError({
|
|
55
|
+
message: "The turnstile capability is not configured.",
|
|
56
|
+
action: "Add `turnstile({ ... })` to a worker's pithy.config.ts (run `pithy add turnstile`).",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return cap.turnstileConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The widget modes declared in config, or an actionable error when none are. */
|
|
63
|
+
function resolveModes(config: TurnstileConfig): TurnstileMode[] {
|
|
64
|
+
const modes = enabledModes(config);
|
|
65
|
+
if (modes.length === 0) {
|
|
66
|
+
throw new ValidationError({
|
|
67
|
+
message: "No Turnstile widgets are declared.",
|
|
68
|
+
action: "Add a `widgets.visible` or `widgets.invisible` entry to turnstile({ ... }) in pithy.config.ts.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return modes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
|
|
76
|
+
*
|
|
77
|
+
* The account is a parameter rather than an ambient, so this cannot resolve before something has
|
|
78
|
+
* established which account the project is for (#206).
|
|
79
|
+
*
|
|
80
|
+
* It also carries **what vouches for the account** (#378). A bare id is what every destructive and
|
|
81
|
+
* creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
|
|
82
|
+
* "I asked an account nothing claims" — the two arrive as one empty listing.
|
|
83
|
+
*/
|
|
84
|
+
function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
|
|
85
|
+
account: ConfirmedAccount;
|
|
86
|
+
accountId: string;
|
|
87
|
+
apiToken: string;
|
|
88
|
+
} {
|
|
89
|
+
const vars = cloudflareEnv({ account });
|
|
90
|
+
const confirmation = cloudflareAccountConfirmation({ account });
|
|
91
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
92
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
93
|
+
if (!accountId || !apiToken) {
|
|
94
|
+
throw new ValidationError({
|
|
95
|
+
message: "Cloudflare credentials are missing.",
|
|
96
|
+
action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { account: { accountId, confirmation }, accountId, apiToken };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the production hostname the real widget binds to, through the one address resolver.
|
|
104
|
+
*
|
|
105
|
+
* A Turnstile widget is bound to the domain a human loads it on, so this needs a hostname rather than a
|
|
106
|
+
* URL — the resolver returns both and this takes the `hostname` half.
|
|
107
|
+
*
|
|
108
|
+
* Single-environment by design: a widget binds to production, so `prod` is not a parameter. Every Worker
|
|
109
|
+
* has its own address, so a project with several names one with `--worker` rather than have a widget
|
|
110
|
+
* silently bound to the wrong host.
|
|
111
|
+
*
|
|
112
|
+
* This used to read `env.prod.vars.BASE_URL` directly, with its own parser and its own two errors — one
|
|
113
|
+
* of the three unreconciled derivations #89 replaced. The resolver now prefers the `domains` declaration
|
|
114
|
+
* and falls back to the route and then to that same var, so an adopter who set it by hand still works and
|
|
115
|
+
* one who declared a domain is no longer told to set a var that would duplicate it.
|
|
116
|
+
*/
|
|
117
|
+
async function resolveProductionDomain(worker: ResolvedWorker): Promise<string> {
|
|
118
|
+
const stanza = ((await readWranglerConfig(worker.dir)) as { env?: Record<string, AddressStanza | undefined> }).env
|
|
119
|
+
?.prod;
|
|
120
|
+
let domains: WorkerDomains | undefined;
|
|
121
|
+
try {
|
|
122
|
+
domains = loadWorkerDomains(await loadWorkerConfig(worker.dir));
|
|
123
|
+
} catch {
|
|
124
|
+
// A malformed declaration must not stop a widget being provisioned off a perfectly good route or
|
|
125
|
+
// var. `pithy env` and `pithy deploy` are where a bad `domains` block gets reported.
|
|
126
|
+
domains = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const address = resolveWorkerAddress({ environment: "prod", domains, stanza });
|
|
130
|
+
if (!address) {
|
|
131
|
+
throw new ValidationError({
|
|
132
|
+
message: `${worker.name} has no production address.`,
|
|
133
|
+
action:
|
|
134
|
+
'Declare it in the Worker\'s pithy.config.ts — `domains: { prod: { pattern: "app.example.com", zone: "example.com" } }`. The Turnstile widget binds to that domain.',
|
|
135
|
+
detail: `no domains declaration, route, or vars.BASE_URL resolved for env.prod in ${worker.dir}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return address.hostname;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The Worker whose `wrangler.jsonc` carries the production `BASE_URL` and the per-env sitekey vars. */
|
|
142
|
+
const workerArg = {
|
|
143
|
+
type: "string",
|
|
144
|
+
description: "The web-facing worker whose wrangler.jsonc holds BASE_URL (default: the project's only worker)",
|
|
145
|
+
} as const;
|
|
146
|
+
|
|
147
|
+
const provision = defineCommand({
|
|
148
|
+
meta: {
|
|
149
|
+
name: "provision",
|
|
150
|
+
description: "Wire Turnstile test keys (dev/staging) and provision the production widget",
|
|
151
|
+
},
|
|
152
|
+
args: {
|
|
153
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
154
|
+
worker: { ...workerArg },
|
|
155
|
+
"allow-shared-domain": {
|
|
156
|
+
type: "boolean",
|
|
157
|
+
default: false,
|
|
158
|
+
description: "Provision even though another Turnstile widget already covers the domain",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
run: ({ args }) =>
|
|
162
|
+
withErrorReporting(args.json, async () => {
|
|
163
|
+
const projectDir = process.cwd();
|
|
164
|
+
const config = await loadTurnstileConfig(projectDir);
|
|
165
|
+
const modes = resolveModes(config);
|
|
166
|
+
const { account, accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
167
|
+
const worker = await resolveSingleWorker({
|
|
168
|
+
projectDir,
|
|
169
|
+
...(args.worker !== undefined ? { worker: args.worker } : {}),
|
|
170
|
+
});
|
|
171
|
+
const productionDomain = await resolveProductionDomain(worker);
|
|
172
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
173
|
+
// The project name scopes both the widget names (`<project>-prod-turnstile-<mode>`) and the
|
|
174
|
+
// dispatcher's `<project>-<env>-secrets-write` target — and it is `requireProjectName`, because a
|
|
175
|
+
// guessed one reuses another project's widget and dispatches into its manager (docs/NAMING.md).
|
|
176
|
+
const projectConfig = await loadProject(projectDir);
|
|
177
|
+
const project = requireProjectName(projectConfig);
|
|
178
|
+
// The project's own environment set (#241): what this command fans out across, rather than a
|
|
179
|
+
// pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
|
|
180
|
+
const environments = loadProjectEnvironments(projectConfig);
|
|
181
|
+
const dispatcher = buildSecretDispatcher(accountId, apiToken, project);
|
|
182
|
+
const audit = await buildAudit(projectDir, accountId, apiToken);
|
|
183
|
+
const provisioner = new CloudflareTurnstileProvisioner({
|
|
184
|
+
cf,
|
|
185
|
+
account,
|
|
186
|
+
project,
|
|
187
|
+
projectDir,
|
|
188
|
+
workerDir: worker.dir,
|
|
189
|
+
dispatcher,
|
|
190
|
+
environments,
|
|
191
|
+
audit,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = await provisionTurnstile(provisioner, {
|
|
195
|
+
modes,
|
|
196
|
+
productionDomain,
|
|
197
|
+
allowSharedDomain: args["allow-shared-domain"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (args.json) {
|
|
201
|
+
process.stdout.write(`${formatJsonLine({ command: "turnstile provision", ...result })}\n`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const created = result.widgets.filter((w) => w.created).length;
|
|
205
|
+
process.stdout.write(
|
|
206
|
+
`Test secret wired for dev and staging. ${result.widgets.length} production widget(s) ready (${created} new).\n`,
|
|
207
|
+
);
|
|
208
|
+
if (result.widgets.length > 0 && !result.productionSecretWritten) {
|
|
209
|
+
// All production widgets already existed, so their secret can't be recomposed (Cloudflare never
|
|
210
|
+
// returns it) and was left as-is. If it was never stored, re-running won't heal it — say so.
|
|
211
|
+
process.stdout.write(
|
|
212
|
+
"Production widgets already existed; their secret was left as-is. If the production gate returns turnstile/config, run `pithy turnstile deprovision` then provision again.\n",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const deprovision = defineCommand({
|
|
220
|
+
meta: { name: "deprovision", description: "Delete the production widget(s) and clear Turnstile config" },
|
|
221
|
+
args: {
|
|
222
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
223
|
+
worker: { ...workerArg },
|
|
224
|
+
},
|
|
225
|
+
run: ({ args }) =>
|
|
226
|
+
withErrorReporting(args.json, async () => {
|
|
227
|
+
const projectDir = process.cwd();
|
|
228
|
+
const config = await loadTurnstileConfig(projectDir);
|
|
229
|
+
const modes = resolveModes(config);
|
|
230
|
+
const { account, accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
231
|
+
const worker = await resolveSingleWorker({
|
|
232
|
+
projectDir,
|
|
233
|
+
...(args.worker !== undefined ? { worker: args.worker } : {}),
|
|
234
|
+
});
|
|
235
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
236
|
+
// The project name scopes both the widget names teardown recomputes and the dispatcher's
|
|
237
|
+
// `<project>-<env>-secrets-write` target — and it is `requireProjectName`, because a guessed one
|
|
238
|
+
// would delete a neighboring project's widget (docs/NAMING.md).
|
|
239
|
+
const projectConfig = await loadProject(projectDir);
|
|
240
|
+
const project = requireProjectName(projectConfig);
|
|
241
|
+
// The project's own environment set (#241): what this command fans out across, rather than a
|
|
242
|
+
// pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
|
|
243
|
+
const environments = loadProjectEnvironments(projectConfig);
|
|
244
|
+
const dispatcher = buildSecretDispatcher(accountId, apiToken, project);
|
|
245
|
+
const audit = await buildAudit(projectDir, accountId, apiToken);
|
|
246
|
+
const deprovisioner = new CloudflareTurnstileDeprovisioner({
|
|
247
|
+
cf,
|
|
248
|
+
account,
|
|
249
|
+
project,
|
|
250
|
+
projectDir,
|
|
251
|
+
workerDir: worker.dir,
|
|
252
|
+
dispatcher,
|
|
253
|
+
environments,
|
|
254
|
+
audit,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const result = await deprovisionTurnstile(deprovisioner, modes);
|
|
258
|
+
|
|
259
|
+
if (args.json) {
|
|
260
|
+
process.stdout.write(`${formatJsonLine({ command: "turnstile deprovision", ...result })}\n`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write(`Production widget(s) removed and config cleared.\n`);
|
|
264
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
export default defineCommand({
|
|
269
|
+
meta: { name: "turnstile", description: "Provision and manage Turnstile widgets" },
|
|
270
|
+
subCommands: { provision, deprovision },
|
|
271
|
+
});
|