@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,652 @@
|
|
|
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 { DEFAULT_ENVIRONMENTS, type DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
7
|
+
import { environmentScope } from "@pithy-sh/core/src/naming/provisionScope";
|
|
8
|
+
import {
|
|
9
|
+
environmentsWrittenBeforeFailure,
|
|
10
|
+
type SecretDispatcher,
|
|
11
|
+
type SecretProbe,
|
|
12
|
+
type SecretRotationRecorder,
|
|
13
|
+
} from "@pithy-sh/secrets/src/cli/dispatch";
|
|
14
|
+
import { secretWriteTargets } from "@pithy-sh/secrets/src/cli/writeTargets";
|
|
15
|
+
import { deprovisionSecrets, provisionSecrets } from "@pithy-sh/secrets/src/provision/provisionSecrets";
|
|
16
|
+
import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
17
|
+
import { canonicalGlobalEnvironment, type ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
18
|
+
import { defineCommand } from "citty";
|
|
19
|
+
import { createProjectCliAudit } from "../audit/cliAudit";
|
|
20
|
+
import {
|
|
21
|
+
type MintedSecret,
|
|
22
|
+
mintDeclaredSecrets,
|
|
23
|
+
mintedBeforeFailure,
|
|
24
|
+
mintReportLines,
|
|
25
|
+
storeSecretMinter,
|
|
26
|
+
} from "../capabilities/mintSecrets";
|
|
27
|
+
import {
|
|
28
|
+
EXIT_ROLLED_NOT_RECORDED,
|
|
29
|
+
rotationReportLines,
|
|
30
|
+
runSecretRotation,
|
|
31
|
+
unrecordedFailure,
|
|
32
|
+
} from "../capabilities/rotateSecrets";
|
|
33
|
+
import { resolveSecretRegistry, runSecretWrite } from "../capabilities/secrets";
|
|
34
|
+
import { buildSecretDispatcher } from "../capabilities/secretsDispatcher";
|
|
35
|
+
import {
|
|
36
|
+
buildManagerDeploy,
|
|
37
|
+
CloudflareSecretsDeprovisioner,
|
|
38
|
+
CloudflareSecretsProvisioner,
|
|
39
|
+
} from "../capabilities/secretsProvisioner";
|
|
40
|
+
import type { ConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
41
|
+
import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
42
|
+
import { editDevSecrets } from "../devSecrets/edit";
|
|
43
|
+
import { resolveDevSecretsFile } from "../devSecrets/location";
|
|
44
|
+
import { mergedSecretRegistry, resolveDevSecretsTargets } from "../devSecrets/targets";
|
|
45
|
+
import { loadProject, projectCloudflareAccount, projectEnvironments, requireProjectName } from "../project/config";
|
|
46
|
+
import { requireManagedEnvironment } from "../project/environment";
|
|
47
|
+
import { resolveWorkers } from "../project/workerScope";
|
|
48
|
+
import { secretsStoreBindings, workerSecretRegistry } from "../provision/secretBindings";
|
|
49
|
+
import { cloudflareSecretsStore } from "../provision/store";
|
|
50
|
+
import { applySecretBindings } from "../provision/wranglerEnv";
|
|
51
|
+
import {
|
|
52
|
+
formatDone,
|
|
53
|
+
formatError,
|
|
54
|
+
formatErrorJson,
|
|
55
|
+
formatJsonLine,
|
|
56
|
+
formatList,
|
|
57
|
+
withErrorReporting,
|
|
58
|
+
} from "../terminal/output";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The secret registry for the whole project: every Worker's, merged by secret name.
|
|
62
|
+
*
|
|
63
|
+
* Capabilities are per Worker, so the registry is too. The secret **name** is the join key — the same name
|
|
64
|
+
* resolves the same value through any registry that declares it — so `pithy secrets` must see every declared
|
|
65
|
+
* name, not just the alphabetically-first Worker's. A Worker that does not compose `secrets` simply
|
|
66
|
+
* contributes nothing; when no Worker does, the capability's own actionable error is what surfaces.
|
|
67
|
+
*/
|
|
68
|
+
async function projectSecretRegistry(projectDir: string): Promise<SecretRegistry> {
|
|
69
|
+
const workers = await resolveWorkers({ projectDir });
|
|
70
|
+
const registries: SecretRegistry[] = [];
|
|
71
|
+
let absent: unknown;
|
|
72
|
+
for (const worker of workers) {
|
|
73
|
+
try {
|
|
74
|
+
registries.push(resolveSecretRegistry(worker.config));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
absent = error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const first = registries[0];
|
|
80
|
+
if (!first) throw absent;
|
|
81
|
+
return registries.length === 1 ? first : (Object.assign({}, ...registries) as SecretRegistry);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The audit emitter for a secrets command. Every value-touching write is a warning-severity event
|
|
86
|
+
* (CLAUDE.md §Security), so this is built for every write and provisioning call — a no-op when
|
|
87
|
+
* Cloudflare credentials or the audit capability aren't there, never a blocker.
|
|
88
|
+
*/
|
|
89
|
+
async function buildAudit(projectDir: string, env: string) {
|
|
90
|
+
const vars = cloudflareEnv({ account: await projectCloudflareAccount(projectDir) });
|
|
91
|
+
// Auditing spans the project, not one Worker: `audit` composed anywhere means the trail exists. Here
|
|
92
|
+
// `env` really is the environment acted on, so it is also the recorded origin.
|
|
93
|
+
return createProjectCliAudit({
|
|
94
|
+
projectDir,
|
|
95
|
+
accountId: vars.CLOUDFLARE_ACCOUNT_ID,
|
|
96
|
+
apiToken: vars.CLOUDFLARE_API_TOKEN,
|
|
97
|
+
env,
|
|
98
|
+
actedOn: env,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The dispatcher a `--dry-run` gets: every seam present, none of them reachable, and none of them called.
|
|
104
|
+
*
|
|
105
|
+
* A dry run answers what *would* happen and must reach no account at all — which is what makes it usable
|
|
106
|
+
* before the credentials exist. `rotateSecretValue` returns `unchanged` before it opens a rotation row, so
|
|
107
|
+
* `openRotation` here is unreachable rather than merely unused; it throws instead of returning a plausible
|
|
108
|
+
* id, because a dry run that quietly recorded a rotation would be the one thing it promises never to do.
|
|
109
|
+
*/
|
|
110
|
+
const DRY_RUN_DISPATCHER: SecretDispatcher & SecretRotationRecorder = {
|
|
111
|
+
dispatch: async () => {},
|
|
112
|
+
openRotation: async () => {
|
|
113
|
+
throw new ValidationError({
|
|
114
|
+
message: "A dry run does not record a rotation.",
|
|
115
|
+
detail: "DRY_RUN_DISPATCHER.openRotation was reached, which means a dry run passed the ledger open",
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
closeRotation: async () => {},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the live dispatcher from CF creds (`.dev.vars`, then `process.env`) and the project name.
|
|
123
|
+
*
|
|
124
|
+
* `requireProjectName`, never `resolveProjectName`: the target Workflow is `<project>-<env>-secrets-write`
|
|
125
|
+
* and Workflow names are account-scoped, so a fallback-derived name would either dispatch nowhere or
|
|
126
|
+
* dispatch this project's values into another project's manager.
|
|
127
|
+
*/
|
|
128
|
+
async function buildDispatcher(projectDir: string): Promise<SecretDispatcher & SecretProbe & SecretRotationRecorder> {
|
|
129
|
+
const { accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
130
|
+
const project = requireProjectName(await loadProject(projectDir));
|
|
131
|
+
return buildSecretDispatcher(accountId, apiToken, project);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The CF credentials and Secrets Store id provisioning needs, from `.dev.vars` then `process.env`. */
|
|
135
|
+
function loadCloudflareCreds(
|
|
136
|
+
account: CloudflareAccountSelection | null,
|
|
137
|
+
options: { requireStore?: boolean } = {},
|
|
138
|
+
): {
|
|
139
|
+
account: ConfirmedAccount;
|
|
140
|
+
accountId: string;
|
|
141
|
+
apiToken: string;
|
|
142
|
+
storeId: string;
|
|
143
|
+
} {
|
|
144
|
+
const vars = cloudflareEnv({ account });
|
|
145
|
+
const confirmation = cloudflareAccountConfirmation({ account });
|
|
146
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
147
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
148
|
+
const storeId = vars.SECRETS_STORE_ID ?? "";
|
|
149
|
+
if (!accountId || !apiToken) {
|
|
150
|
+
throw new ValidationError({
|
|
151
|
+
message: "Cloudflare credentials are missing.",
|
|
152
|
+
action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (options.requireStore && !storeId) {
|
|
156
|
+
throw new ValidationError({
|
|
157
|
+
message: "The CF Secrets Store id is missing.",
|
|
158
|
+
action: "Run pithy add secrets to record SECRETS_STORE_ID (create a Secrets Store in the Cloudflare dashboard).",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return { account: { accountId, confirmation }, accountId, apiToken, storeId };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Read the secret value: from stdin when it is piped (agent/non-interactive use), otherwise from a
|
|
166
|
+
* masked prompt. Never from a flag — a value there would persist in shell history and process lists.
|
|
167
|
+
*/
|
|
168
|
+
async function readValue(name: string): Promise<string> {
|
|
169
|
+
if (!process.stdin.isTTY) {
|
|
170
|
+
const chunks: Buffer[] = [];
|
|
171
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
172
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\n$/, "");
|
|
173
|
+
}
|
|
174
|
+
const { isCancel, password } = await import("@clack/prompts");
|
|
175
|
+
const answer = await password({ message: `Value for '${name}'` });
|
|
176
|
+
if (isCancel(answer)) {
|
|
177
|
+
process.stderr.write("Canceled.\n");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
return answer;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* **Ask the rule before asking for a value.**
|
|
185
|
+
*
|
|
186
|
+
* `secretWriteTargets` is what decides whether a write is coherent, and `dispatchSecretWrite` asks it
|
|
187
|
+
* again with nothing sent if the answer is no — that second call is what makes it a guarantee rather
|
|
188
|
+
* than a courtesy. This one exists so an operator is not prompted to type a production signing key into
|
|
189
|
+
* a command that was never going to run.
|
|
190
|
+
*
|
|
191
|
+
* The refusals `runSecretWrite` owns — an undeclared name, a keyspace — are left to it. Answering them
|
|
192
|
+
* here as well would be a second producer of two more rules.
|
|
193
|
+
*/
|
|
194
|
+
function checkWriteIsCoherent(
|
|
195
|
+
registry: SecretRegistry,
|
|
196
|
+
mode: "create" | "update" | "delete",
|
|
197
|
+
name: string,
|
|
198
|
+
requested: ManagedEnvironment | undefined,
|
|
199
|
+
declared: DeclaredEnvironments,
|
|
200
|
+
): void {
|
|
201
|
+
const entry = registry[name];
|
|
202
|
+
if (!entry || entry.keyed) return;
|
|
203
|
+
secretWriteTargets({ name, backend: entry.backend, scope: entry.scope, mode, requested, declared });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The environment a write's **audit** is recorded from — never the write's target, which
|
|
208
|
+
* `secretWriteTargets` decides from what the operator actually typed.
|
|
209
|
+
*
|
|
210
|
+
* A global write has no single origin, so the canonical environment stands in: the trail has to name
|
|
211
|
+
* somewhere, and the canonical one is the manager a `cf-secrets-store` write goes through anyway. The
|
|
212
|
+
* environments a run *reached* are in the event's metadata, which is the field that answers where a
|
|
213
|
+
* value landed.
|
|
214
|
+
*/
|
|
215
|
+
function auditOrigin(requested: ManagedEnvironment | undefined, declared: DeclaredEnvironments): string {
|
|
216
|
+
return requested ?? canonicalGlobalEnvironment(declared) ?? declared[0] ?? "prod";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Shared body for create/update/rm: discover the registry, dispatch, and report the envs written. */
|
|
220
|
+
async function write(
|
|
221
|
+
mode: "create" | "update" | "delete",
|
|
222
|
+
args: { name: string; env?: string; json: boolean },
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const projectDir = process.cwd();
|
|
225
|
+
const registry = await projectSecretRegistry(projectDir);
|
|
226
|
+
const environments = await projectEnvironments(projectDir);
|
|
227
|
+
// `--env` as the operator gave it, or nothing. Not resolved to a default: the absence is the whole
|
|
228
|
+
// difference between *narrow this write* and *say nothing*, and it is what the rule turns on.
|
|
229
|
+
const env = args.env ? requireManagedEnvironment(args.env, environments) : undefined;
|
|
230
|
+
checkWriteIsCoherent(registry, mode, args.name, env, environments);
|
|
231
|
+
const value = mode === "delete" ? undefined : await readValue(args.name);
|
|
232
|
+
const dispatcher = await buildDispatcher(projectDir);
|
|
233
|
+
const audit = await buildAudit(projectDir, auditOrigin(env, environments));
|
|
234
|
+
|
|
235
|
+
let targets: ManagedEnvironment[];
|
|
236
|
+
try {
|
|
237
|
+
targets = await runSecretWrite(registry, dispatcher, { mode, name: args.name, value, env, environments }, audit);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// **A fan-out has no rollback, so what it wrote is said before the error is.** Three environments and
|
|
240
|
+
// the third throws leaves the first two holding the new value; without this the operator reads a
|
|
241
|
+
// failure and has no way to know that. `withErrorReporting` then puts `{ error }` on stderr and exits
|
|
242
|
+
// 1 — the streams agree, and neither reports success.
|
|
243
|
+
const written = environmentsWrittenBeforeFailure(error);
|
|
244
|
+
if (written.length > 0) {
|
|
245
|
+
const landed = mode === "delete" ? "removed from" : "written to";
|
|
246
|
+
process.stdout.write(
|
|
247
|
+
args.json
|
|
248
|
+
? `${formatJsonLine({ command: `secrets ${mode}`, name: args.name, environments: written, interrupted: true })}\n`
|
|
249
|
+
: `${args.name} ${landed} ${written.join(", ")} before this failed.\n`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (args.json) {
|
|
256
|
+
process.stdout.write(`${formatJsonLine({ command: `secrets ${mode}`, name: args.name, environments: targets })}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
process.stdout.write(`${args.name} ${mode === "delete" ? "removed from" : "written to"} ${targets.join(", ")}.\n`);
|
|
260
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const nameArg = {
|
|
264
|
+
name: { type: "positional", required: true, description: "Secret name (a registry entry)." },
|
|
265
|
+
} as const;
|
|
266
|
+
const sharedArgs = {
|
|
267
|
+
env: {
|
|
268
|
+
type: "string",
|
|
269
|
+
// Resolved when the command tree is built, before any project is read, so this names the default set
|
|
270
|
+
// and the refusal names the project's own — see `requireManagedEnvironment`.
|
|
271
|
+
description: `Target environment for an environment-scoped secret: ${DEFAULT_ENVIRONMENTS.join(" | ")}, or one declared in pithy.config.ts`,
|
|
272
|
+
},
|
|
273
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
274
|
+
} as const;
|
|
275
|
+
|
|
276
|
+
const create = defineCommand({
|
|
277
|
+
meta: { name: "create", description: "Create a secret (fails if it already exists)" },
|
|
278
|
+
args: { ...nameArg, ...sharedArgs },
|
|
279
|
+
run: ({ args }) => withErrorReporting(args.json, () => write("create", args)),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const update = defineCommand({
|
|
283
|
+
meta: { name: "update", description: "Update a secret (fails if it doesn't exist)" },
|
|
284
|
+
args: { ...nameArg, ...sharedArgs },
|
|
285
|
+
run: ({ args }) => withErrorReporting(args.json, () => write("update", args)),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const rm = defineCommand({
|
|
289
|
+
meta: { name: "rm", description: "Remove a secret" },
|
|
290
|
+
args: { ...nameArg, ...sharedArgs },
|
|
291
|
+
run: ({ args }) => withErrorReporting(args.json, () => write("delete", args)),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `pithy secrets rotate` — replace one secret's value against the rotation its registry entry declares.
|
|
296
|
+
*
|
|
297
|
+
* **One secret per invocation. There is no `--all`, and that is a decision rather than an omission.**
|
|
298
|
+
*
|
|
299
|
+
* The case that wants one is real: somebody has left, and every credential they could have seen needs
|
|
300
|
+
* rolling today. The dashboard solved the same problem for connection signing keys and settled on *more
|
|
301
|
+
* than one confirmation, plus an audit entry naming the operator* — and the second half is the half this
|
|
302
|
+
* command cannot honor. `createCliAudit` resolves the actor from the Cloudflare API token and falls back
|
|
303
|
+
* to `system, actorResolutionFailed` when there is none, so the one act most certain to be reviewed
|
|
304
|
+
* afterwards would be recorded as *somebody with the token*. A fleet path that cannot say who took it is
|
|
305
|
+
* worse than no fleet path, because it is the difference between an incident with a name on it and an
|
|
306
|
+
* incident without one.
|
|
307
|
+
*
|
|
308
|
+
* The blast radius argues the same way from the other end. The failure this command is built around —
|
|
309
|
+
* rolled at the issuer, not recorded — does not average out over ten secrets; it is ten chances to strand
|
|
310
|
+
* a live credential inside one invocation, reported into one scrollback, at the hour an operator is least
|
|
311
|
+
* able to read carefully. A flag one character from the ordinary command is the wrong place for that.
|
|
312
|
+
*
|
|
313
|
+
* **What the case gets instead**: `pithy secrets ls` names every declared secret, and a shell loop over it
|
|
314
|
+
* makes the operator see the list they are about to roll before they roll it. That is a worse ergonomic
|
|
315
|
+
* and a better 2am.
|
|
316
|
+
*
|
|
317
|
+
* `--dry-run` is here because it costs almost nothing and answers the question an operator has just before
|
|
318
|
+
* the irreversible one: *is this secret rolled at somebody else's API, or minted here?*
|
|
319
|
+
*/
|
|
320
|
+
const rotate = defineCommand({
|
|
321
|
+
meta: { name: "rotate", description: "Rotate one secret against its declared rotator" },
|
|
322
|
+
args: {
|
|
323
|
+
...nameArg,
|
|
324
|
+
...sharedArgs,
|
|
325
|
+
"dry-run": { type: "boolean", default: false, description: "Say what would happen; call nothing" },
|
|
326
|
+
},
|
|
327
|
+
run: ({ args }) =>
|
|
328
|
+
withErrorReporting(args.json, async () => {
|
|
329
|
+
const projectDir = process.cwd();
|
|
330
|
+
const registry = await projectSecretRegistry(projectDir);
|
|
331
|
+
const environments = await projectEnvironments(projectDir);
|
|
332
|
+
const env = args.env ? requireManagedEnvironment(args.env, environments) : undefined;
|
|
333
|
+
const entry = registry[args.name];
|
|
334
|
+
const dryRun = args["dry-run"];
|
|
335
|
+
|
|
336
|
+
// **The dispatcher is built before anything is rolled**, and the ordering is load-bearing rather
|
|
337
|
+
// than tidy. Missing credentials raise here, with the previous value untouched; built after the
|
|
338
|
+
// roll, the same missing credentials would strand a live one behind a message about `pithy init`.
|
|
339
|
+
// A dry run reaches no account at all, which is what makes it usable before the credentials exist.
|
|
340
|
+
const dispatcher = dryRun ? DRY_RUN_DISPATCHER : await buildDispatcher(projectDir);
|
|
341
|
+
const audit = dryRun ? async () => {} : await buildAudit(projectDir, auditOrigin(env, environments));
|
|
342
|
+
|
|
343
|
+
const outcome = await runSecretRotation(
|
|
344
|
+
registry,
|
|
345
|
+
dispatcher,
|
|
346
|
+
{ name: args.name, env, environments, dryRun },
|
|
347
|
+
audit,
|
|
348
|
+
);
|
|
349
|
+
// `runSecretRotation` refuses an undeclared name before anything else happens, so reaching here with
|
|
350
|
+
// no entry is impossible — this narrows for the type checker rather than for a state that can occur.
|
|
351
|
+
if (!entry) throw new ValidationError({ message: `Secret '${args.name}' is not declared in the registry.` });
|
|
352
|
+
|
|
353
|
+
if (args.json) {
|
|
354
|
+
const rotations = [
|
|
355
|
+
{
|
|
356
|
+
name: outcome.name,
|
|
357
|
+
status: outcome.status,
|
|
358
|
+
rotation: outcome.kind,
|
|
359
|
+
rolled: outcome.rolled,
|
|
360
|
+
...(outcome.rollFailed === undefined ? {} : { rollFailed: outcome.rollFailed }),
|
|
361
|
+
recorded: outcome.recorded,
|
|
362
|
+
stranded: outcome.stranded,
|
|
363
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
process.stdout.write(`${formatJsonLine({ command: "secrets rotate", name: args.name, rotations })}\n`);
|
|
367
|
+
} else {
|
|
368
|
+
for (const line of rotationReportLines(entry, outcome, env)) process.stdout.write(`${line}\n`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// **The two ends of the same run, and they must never disagree.** Whatever the outcome, stdout has
|
|
372
|
+
// already said per secret what happened; these decide what the shell learns.
|
|
373
|
+
if (outcome.status === "unrecorded") {
|
|
374
|
+
const failure = unrecordedFailure(entry, outcome, env);
|
|
375
|
+
process.stderr.write(`${args.json ? formatErrorJson(failure.payload) : formatError(failure.payload)}\n`);
|
|
376
|
+
// Not a throw: `withErrorReporting` would exit 1, and 1 is the status that means *the previous
|
|
377
|
+
// credential is still live*. This state is the one thing in the command that is not that.
|
|
378
|
+
process.exitCode = EXIT_ROLLED_NOT_RECORDED;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (outcome.status === "failed") {
|
|
382
|
+
// Ordinary: nothing was rolled, so the previous value is still live and the run can be repeated.
|
|
383
|
+
// The cause is what the operator needs, and `withErrorReporting` puts it on stderr with exit 1. The
|
|
384
|
+
// fallback is not decoration — `throw undefined` would exit non-zero with a blank stderr, which is
|
|
385
|
+
// the one report worse than a bad one.
|
|
386
|
+
throw (
|
|
387
|
+
outcome.cause ??
|
|
388
|
+
new ValidationError({
|
|
389
|
+
message: `Secret '${args.name}' was not rotated.`,
|
|
390
|
+
action: "Run it again. The previous value is still live.",
|
|
391
|
+
detail: `rotate '${args.name}': store refused with no recorded cause`,
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (args.json) return;
|
|
396
|
+
if (dryRun) {
|
|
397
|
+
process.stdout.write("Dry run. Nothing rolled, nothing written.\n");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// **No `Done.` over a `manual` secret.** The lines above have just told the operator that a human has
|
|
401
|
+
// to go to a console, and `Done.` under them reads as the command having handled it. The last thing
|
|
402
|
+
// they see is the instruction, which is the only thing left to act on.
|
|
403
|
+
if (outcome.reason === "manual") return;
|
|
404
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
405
|
+
}),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const ls = defineCommand({
|
|
409
|
+
meta: { name: "ls", description: "List the declared secrets" },
|
|
410
|
+
args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
|
|
411
|
+
run: ({ args }) =>
|
|
412
|
+
withErrorReporting(args.json, async () => {
|
|
413
|
+
const registry = await projectSecretRegistry(process.cwd());
|
|
414
|
+
const rows = Object.entries(registry)
|
|
415
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
416
|
+
.map(([name, entry]) => ({
|
|
417
|
+
name,
|
|
418
|
+
// A keyspace is marked, because it is the one entry an operator must not try to set: its
|
|
419
|
+
// members are written per key by the application that mints them.
|
|
420
|
+
description: `${entry.backend} · ${entry.scope}${entry.rotatable ? " · rotatable" : ""}${entry.keyed ? " · keyspace" : ""}`,
|
|
421
|
+
}));
|
|
422
|
+
if (args.json) {
|
|
423
|
+
process.stdout.write(`${formatJsonLine({ command: "secrets ls", secrets: rows })}\n`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
process.stdout.write(`${formatList(rows)}\n`);
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* `pithy secrets edit` — the local dev values, in the adopter's editor (#157).
|
|
432
|
+
*
|
|
433
|
+
* The odd one out in this file, and deliberately: every other subcommand here writes a **managed**
|
|
434
|
+
* secret through the manager Workflow, and this one touches nothing but the machine-local file at
|
|
435
|
+
* `<config>/<project>/secrets.jsonc`. They are siblings because they are the same question — "where does
|
|
436
|
+
* this value live" — asked about the two environments a project has.
|
|
437
|
+
*
|
|
438
|
+
* It resolves the path, opens it, validates what comes back, and writes it atomically at `0600`. It
|
|
439
|
+
* prints a path and a count, and never a name or a value: `secrets ls` is what lists names.
|
|
440
|
+
*/
|
|
441
|
+
const edit = defineCommand({
|
|
442
|
+
meta: { name: "edit", description: "Edit this machine's dev secret values in your editor" },
|
|
443
|
+
args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
|
|
444
|
+
run: ({ args }) =>
|
|
445
|
+
withErrorReporting(args.json, async () => {
|
|
446
|
+
// The one resolution of where the file is (`devSecrets/location.ts`). It requires a project name
|
|
447
|
+
// rather than guessing one: a guess would open one checkout's secrets from another's worktree.
|
|
448
|
+
const path = await resolveDevSecretsFile(process.cwd());
|
|
449
|
+
// Best effort, and never a reason to refuse (#323). With a registry an edit is judged against the
|
|
450
|
+
// payload each secret's destination takes; without one the file is still checked as JSONC. A
|
|
451
|
+
// project whose config will not load is the state this command exists to get somebody out of.
|
|
452
|
+
const targets = await resolveDevSecretsTargets(process.cwd())
|
|
453
|
+
.then((resolved) => resolved.targets)
|
|
454
|
+
.catch(() => []);
|
|
455
|
+
const result = await editDevSecrets({ path, registry: mergedSecretRegistry(targets) });
|
|
456
|
+
|
|
457
|
+
if (args.json) {
|
|
458
|
+
process.stdout.write(
|
|
459
|
+
`${formatJsonLine({ command: "secrets edit", path, changed: result.changed, secrets: result.secrets })}\n`,
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
process.stdout.write(
|
|
464
|
+
result.changed
|
|
465
|
+
? `${path} written. ${result.secrets} ${result.secrets === 1 ? "secret" : "secrets"}.\n`
|
|
466
|
+
: `${path} unchanged.\n`,
|
|
467
|
+
);
|
|
468
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const provision = defineCommand({
|
|
473
|
+
meta: { name: "provision", description: "Provision the per-environment secrets infrastructure" },
|
|
474
|
+
args: { json: { type: "boolean", default: false, description: "Machine-readable output" } },
|
|
475
|
+
run: ({ args }) =>
|
|
476
|
+
withErrorReporting(args.json, async () => {
|
|
477
|
+
const projectDir = process.cwd();
|
|
478
|
+
const { account, accountId, apiToken, storeId } = loadCloudflareCreds(
|
|
479
|
+
await projectCloudflareAccount(projectDir),
|
|
480
|
+
{
|
|
481
|
+
requireStore: true,
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
// Never `resolveProjectName`: every Secrets Store entry and the manager's token name derive from
|
|
485
|
+
// this, and deprovision has to recompute them exactly. A guessed name would name resources
|
|
486
|
+
// teardown can never find again.
|
|
487
|
+
const project = requireProjectName(await loadProject(projectDir));
|
|
488
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
489
|
+
// Provisioning spans every managed environment, not one — "dev" is the fallback the audit
|
|
490
|
+
// database resolves against when a command has no single target env (mirrors `pithy feature`).
|
|
491
|
+
const provisioner = new CloudflareSecretsProvisioner({
|
|
492
|
+
cf,
|
|
493
|
+
account,
|
|
494
|
+
project,
|
|
495
|
+
storeId,
|
|
496
|
+
deploy: buildManagerDeploy({ accountId, apiToken, project }),
|
|
497
|
+
audit: await buildAudit(projectDir, "dev"),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const environments = await projectEnvironments(projectDir);
|
|
501
|
+
const result = await provisionSecrets(provisioner, environments);
|
|
502
|
+
|
|
503
|
+
// **The step the deferral was deferring to (#238).** `pithy add` cannot write a `secret` binding —
|
|
504
|
+
// the entry needs a `store_id` and a `secret_name` that do not exist until an account has been
|
|
505
|
+
// reached — and `ensureSecretsStoreId` records nothing in five cases besides. Provisioning is when
|
|
506
|
+
// the store certainly exists and every entry has certainly been written, so this is where the
|
|
507
|
+
// adopter's own Workers get the stanza. It corrects an existing entry rather than duplicating it,
|
|
508
|
+
// and leaves a binding this registry does not declare exactly where the adopter put it.
|
|
509
|
+
//
|
|
510
|
+
// `dev` is deliberately not among them: local dev materialises every `cf-secrets-store` secret into
|
|
511
|
+
// the generated `.dev.vars` (#179), so a stanza there would name entries a local run never reads.
|
|
512
|
+
const store = cloudflareSecretsStore(cf, storeId);
|
|
513
|
+
// One emitter per environment, resolved once: `buildAudit` reaches the account and the Worker set,
|
|
514
|
+
// and it is the same answer for every Worker in a given environment.
|
|
515
|
+
const audits = new Map(
|
|
516
|
+
await Promise.all(environments.map(async (env) => [env, await buildAudit(projectDir, env)] as const)),
|
|
517
|
+
);
|
|
518
|
+
const wired: { worker: string; env: string; bindings: string[]; created: string[] }[] = [];
|
|
519
|
+
for (const worker of await resolveWorkers({ projectDir })) {
|
|
520
|
+
const registry = workerSecretRegistry(worker.capabilities);
|
|
521
|
+
if (!registry) continue;
|
|
522
|
+
for (const env of environments) {
|
|
523
|
+
const { bound, minted } = await secretsStoreBindings({
|
|
524
|
+
registry,
|
|
525
|
+
scope: environmentScope(project, env),
|
|
526
|
+
storeId,
|
|
527
|
+
exists: (name) => store.exists(name),
|
|
528
|
+
// Every environment's master key exists by now, so this is the point where a declared
|
|
529
|
+
// mintable secret can be created and bound in one pass rather than named as homework (#321).
|
|
530
|
+
mint: storeSecretMinter({
|
|
531
|
+
store,
|
|
532
|
+
environment: env,
|
|
533
|
+
...(audits.get(env) ? { audit: audits.get(env) } : {}),
|
|
534
|
+
}),
|
|
535
|
+
});
|
|
536
|
+
if (bound.length === 0) continue;
|
|
537
|
+
await applySecretBindings(worker.dir, env, bound);
|
|
538
|
+
wired.push({ worker: worker.name, env, bindings: bound.map((entry) => entry.binding), created: minted });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// **The other half of #321, and the half its own commit message describes.** The loop above creates
|
|
543
|
+
// the `cf-secrets-store` secrets a Worker binds; every secret the *kit* declares arbitrary — the
|
|
544
|
+
// auth session secret, the email link-signing key — is `d1`, and until now provisioning finished by
|
|
545
|
+
// telling an operator to go and generate random bytes for each. This is the point where it can stop
|
|
546
|
+
// doing that: `provisionSecrets` above has deployed each environment's manager, and the manager is
|
|
547
|
+
// the only thing that can decide whether one of these already exists, because its value is sealed
|
|
548
|
+
// under a master key the CLI never holds. So the managers are **asked** first, across every
|
|
549
|
+
// environment at once, and only then written to. See `capabilities/mintSecrets.ts`.
|
|
550
|
+
//
|
|
551
|
+
// One audit emitter is picked rather than one per environment: this loop spans every environment
|
|
552
|
+
// a `global` secret reaches, and the event's own `environments` field is what says where a value
|
|
553
|
+
// went. `dev` is the same fallback `buildAudit` above uses for a command with no single target.
|
|
554
|
+
//
|
|
555
|
+
// **A run that fails here has usually written something.** The fan-out creates a signing key per
|
|
556
|
+
// environment, so a fault after the first write leaves key material behind — and until #324 the
|
|
557
|
+
// report of it was assembled after the loop and died with the throw. So what landed is caught and
|
|
558
|
+
// printed *before* the error is rethrown: `withErrorReporting` then writes the failure to stderr
|
|
559
|
+
// and exits 1, and stdout carries what the run actually did. Both streams, and the exit code,
|
|
560
|
+
// agree that it failed and name what it wrote on the way.
|
|
561
|
+
const managers = await buildDispatcher(projectDir);
|
|
562
|
+
let generated: MintedSecret[];
|
|
563
|
+
try {
|
|
564
|
+
generated = await mintDeclaredSecrets({
|
|
565
|
+
registry: await projectSecretRegistry(projectDir),
|
|
566
|
+
dispatcher: managers,
|
|
567
|
+
probe: managers,
|
|
568
|
+
environments,
|
|
569
|
+
audit: await buildAudit(projectDir, "dev"),
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
const landed = mintedBeforeFailure(error);
|
|
573
|
+
if (args.json) {
|
|
574
|
+
process.stdout.write(
|
|
575
|
+
`${formatJsonLine({
|
|
576
|
+
command: "secrets provision",
|
|
577
|
+
environments: result.perEnv,
|
|
578
|
+
wired,
|
|
579
|
+
generated: landed,
|
|
580
|
+
interrupted: true,
|
|
581
|
+
})}\n`,
|
|
582
|
+
);
|
|
583
|
+
} else {
|
|
584
|
+
for (const line of mintReportLines(landed)) process.stdout.write(`${line}\n`);
|
|
585
|
+
}
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (args.json) {
|
|
590
|
+
process.stdout.write(
|
|
591
|
+
`${formatJsonLine({ command: "secrets provision", environments: result.perEnv, wired, generated })}\n`,
|
|
592
|
+
);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
for (const env of result.perEnv) {
|
|
596
|
+
process.stdout.write(`${env.env}: database, key, and manager ready.\n`);
|
|
597
|
+
}
|
|
598
|
+
for (const entry of wired) {
|
|
599
|
+
process.stdout.write(`${entry.worker} env.${entry.env} binds ${entry.bindings.join(", ")}.\n`);
|
|
600
|
+
if (entry.created.length > 0) {
|
|
601
|
+
process.stdout.write(`${entry.env}: created ${entry.created.join(", ")}.\n`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// It used to say only "ready", because the manager decided whether a value was written and never
|
|
605
|
+
// reported back. It reports now, so the run can say the thing an operator actually needs to know:
|
|
606
|
+
// whether this run generated a production signing key, or found one already there. The same
|
|
607
|
+
// renderer as the interrupted path above — one phrasing, so a partial report cannot read as a
|
|
608
|
+
// complete one.
|
|
609
|
+
for (const line of mintReportLines(generated)) process.stdout.write(`${line}\n`);
|
|
610
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
611
|
+
}),
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const deprovision = defineCommand({
|
|
615
|
+
meta: { name: "deprovision", description: "Remove the secrets manager workers and databases" },
|
|
616
|
+
args: {
|
|
617
|
+
keys: { type: "boolean", default: false, description: "Also delete the master keys (irreversible)" },
|
|
618
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
619
|
+
},
|
|
620
|
+
run: ({ args }) =>
|
|
621
|
+
withErrorReporting(args.json, async () => {
|
|
622
|
+
const projectDir = process.cwd();
|
|
623
|
+
const { account, accountId, apiToken, storeId } = loadCloudflareCreds(
|
|
624
|
+
await projectCloudflareAccount(projectDir),
|
|
625
|
+
{
|
|
626
|
+
requireStore: true,
|
|
627
|
+
},
|
|
628
|
+
);
|
|
629
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
630
|
+
const deprovisioner = new CloudflareSecretsDeprovisioner({
|
|
631
|
+
account,
|
|
632
|
+
cf,
|
|
633
|
+
project: requireProjectName(await loadProject(projectDir)),
|
|
634
|
+
storeId,
|
|
635
|
+
audit: await buildAudit(projectDir, "dev"),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await deprovisionSecrets(deprovisioner, await projectEnvironments(projectDir), { deleteKeys: args.keys });
|
|
639
|
+
|
|
640
|
+
if (args.json) {
|
|
641
|
+
process.stdout.write(`${formatJsonLine({ command: "secrets deprovision", keysDeleted: args.keys })}\n`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
process.stdout.write(`Secrets infrastructure removed${args.keys ? ", including master keys" : ""}.\n`);
|
|
645
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
646
|
+
}),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
export default defineCommand({
|
|
650
|
+
meta: { name: "secrets", description: "Manage encrypted secrets" },
|
|
651
|
+
subCommands: { create, update, rotate, rm, ls, edit, provision, deprovision },
|
|
652
|
+
});
|