@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,389 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import type { TokenPermission } from "@pithy-sh/cloudflare/src/tokens/accountTokensManager";
|
|
9
|
+
import type { PermissionKey } from "@pithy-sh/cloudflare/src/tokens/permissions";
|
|
10
|
+
import { permissionsForKeys } from "@pithy-sh/cloudflare/src/tokens/profiles";
|
|
11
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
12
|
+
import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
|
|
13
|
+
import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
|
|
14
|
+
import { secretsTokenProfile } from "@pithy-sh/secrets/src/capability";
|
|
15
|
+
import { encodeVersionedValue, initialVersionedValue } from "@pithy-sh/secrets/src/crypto/versionedValue";
|
|
16
|
+
import { secrets_0001_init } from "@pithy-sh/secrets/src/migrations/0001_init";
|
|
17
|
+
import {
|
|
18
|
+
initialMasterKeyConfig,
|
|
19
|
+
managerCfApiTokenName,
|
|
20
|
+
managerCfApiTokenSecretName,
|
|
21
|
+
masterKeySecretName,
|
|
22
|
+
type SecretsDeprovisioner,
|
|
23
|
+
type SecretsProvisioner,
|
|
24
|
+
} from "@pithy-sh/secrets/src/provision/provisionSecrets";
|
|
25
|
+
import {
|
|
26
|
+
type ManagerWranglerTemplate,
|
|
27
|
+
managerWorkerName,
|
|
28
|
+
resolveManagerConfig,
|
|
29
|
+
} from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
|
|
30
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
31
|
+
import { parse } from "comment-json";
|
|
32
|
+
import type { MigrationProvider } from "kysely/migration";
|
|
33
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
34
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
35
|
+
import { runWrangler } from "../project/wrangler";
|
|
36
|
+
|
|
37
|
+
/** The secrets migration set, as provisioning runs it against each environment's D1. */
|
|
38
|
+
function secretsMigrationProvider(): MigrationProvider {
|
|
39
|
+
const registry = createMigrationRegistry([
|
|
40
|
+
{ database: "secrets", namespace: "secrets", order: 100, migrations: { "0001_init": secrets_0001_init } },
|
|
41
|
+
]);
|
|
42
|
+
const provider = registry.secrets;
|
|
43
|
+
if (!provider) throw new Error("missing secrets migration provider");
|
|
44
|
+
return provider;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The injected deploy step — resolve the manager's wrangler config and shell out to wrangler. */
|
|
48
|
+
export type DeployManager = (
|
|
49
|
+
env: ManagedEnvironment,
|
|
50
|
+
resolved: { databaseId: string; storeId: string },
|
|
51
|
+
) => Promise<void>;
|
|
52
|
+
|
|
53
|
+
export interface CloudflareSecretsProvisionerOptions {
|
|
54
|
+
cf: CloudflareClients;
|
|
55
|
+
/** The CF account id, used to scope the minted manager token to this account's resources. */
|
|
56
|
+
/**
|
|
57
|
+
* The account this provisions into, and what vouches for it (#378).
|
|
58
|
+
*
|
|
59
|
+
* Replaces a bare `accountId`, and the replacement is the point: an id on its own is what six sites
|
|
60
|
+
* already held while a find-or-create read an empty listing as "this account has none" and minted a
|
|
61
|
+
* real resource in whichever account the shell had named. The id is still here — `account.accountId` —
|
|
62
|
+
* and it now travels with the answer to "who says so".
|
|
63
|
+
*/
|
|
64
|
+
account: ConfirmedAccount;
|
|
65
|
+
/**
|
|
66
|
+
* The project name (root `pithy.config.ts` `name`, via `requireProjectName`). **Every** name this
|
|
67
|
+
* provisioner creates leads with it: the manager Worker, its D1, both of its Workflows, each Secrets
|
|
68
|
+
* Store entry, and the minted CF API token. All five namespaces are flat and account-wide, so this
|
|
69
|
+
* segment is the only thing stopping a second project from provisioning over this one — and a Worker
|
|
70
|
+
* deploy does not collide, it overwrites.
|
|
71
|
+
*/
|
|
72
|
+
project: string;
|
|
73
|
+
/** The CF Secrets Store id holding the per-env master keys and the manager token. */
|
|
74
|
+
storeId: string;
|
|
75
|
+
/** Deploys the manager worker. Injected so the control-plane steps are testable without wrangler. */
|
|
76
|
+
deploy: DeployManager;
|
|
77
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
78
|
+
audit?: CliAuditEmit;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The least-privilege permissions the manager's minted token carries: Secrets Store Read + Write,
|
|
83
|
+
* scoped to this account. Derived from the predefined `secrets` token profile — the one source of the
|
|
84
|
+
* standard defaults each package needs (`pithy token`) — so the manager's scope and the profile never
|
|
85
|
+
* drift. The manager's only live-CF use is the rotation config write-back; its D1 work runs through
|
|
86
|
+
* the `SECRETS` binding, not this token — so nothing wider is granted.
|
|
87
|
+
*/
|
|
88
|
+
export function managerTokenPermissions(accountId: string): TokenPermission[] {
|
|
89
|
+
return permissionsForKeys([...secretsTokenProfile.permissions] as PermissionKey[], accountId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The live {@link SecretsProvisioner} — the CF + wrangler implementation of `pithy add secrets`.
|
|
94
|
+
* The control-plane steps go through `@pithy-sh/cloudflare` (CLAUDE.md: CF API only via that client)
|
|
95
|
+
* and are each idempotent; the manager deploy is the injected wrangler step. Every step here is
|
|
96
|
+
* exercised against live Cloudflare by the integration suite.
|
|
97
|
+
*/
|
|
98
|
+
export class CloudflareSecretsProvisioner implements SecretsProvisioner {
|
|
99
|
+
readonly #cf: CloudflareClients;
|
|
100
|
+
readonly #account: ConfirmedAccount;
|
|
101
|
+
readonly #project: string;
|
|
102
|
+
readonly #storeId: string;
|
|
103
|
+
readonly #deploy: DeployManager;
|
|
104
|
+
readonly #audit: CliAuditEmit;
|
|
105
|
+
|
|
106
|
+
constructor(options: CloudflareSecretsProvisionerOptions) {
|
|
107
|
+
this.#cf = options.cf;
|
|
108
|
+
this.#account = options.account;
|
|
109
|
+
this.#project = options.project;
|
|
110
|
+
this.#storeId = options.storeId;
|
|
111
|
+
this.#deploy = options.deploy;
|
|
112
|
+
this.#audit = options.audit ?? (async () => {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Require a registered `workers.dev` subdomain — Cloudflare needs one to deploy the managers. */
|
|
116
|
+
async preflight(): Promise<void> {
|
|
117
|
+
if (!(await this.#cf.workers().accountSubdomain())) {
|
|
118
|
+
throw new ValidationError({
|
|
119
|
+
message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
|
|
120
|
+
action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ensure the manager's runtime CF API token. Reuse the value already in the Secrets Store if present
|
|
127
|
+
* (Cloudflare never returns a token's secret twice, so a stored token is trusted as-is); otherwise
|
|
128
|
+
* get a fresh secret via `rollToken` — roll the existing manager token's value in place if one exists,
|
|
129
|
+
* else mint a new least-privilege token — and write it into the store. A bootstrap token that cannot
|
|
130
|
+
* mint fails here, before any resource is created, with an actionable error.
|
|
131
|
+
*/
|
|
132
|
+
async ensureManagerToken(): Promise<void> {
|
|
133
|
+
const entry = managerCfApiTokenSecretName(this.#project);
|
|
134
|
+
const store = this.#cf.secrets(this.#storeId);
|
|
135
|
+
if (await store.exists(entry)) return;
|
|
136
|
+
const minted = await this.#cf
|
|
137
|
+
.accountTokens()
|
|
138
|
+
.rollToken(managerCfApiTokenName(this.#project), managerTokenPermissions(this.#account.accountId));
|
|
139
|
+
await writeManagerCfApiToken(this.#cf, { storeId: this.#storeId, project: this.#project }, minted.value);
|
|
140
|
+
// Never the minted value — just that the manager's own runtime credential was (re)written.
|
|
141
|
+
await this.#audit({
|
|
142
|
+
environment: "global",
|
|
143
|
+
action: "secrets/set",
|
|
144
|
+
outcome: "success",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
resourceType: "secret",
|
|
147
|
+
resourceId: entry,
|
|
148
|
+
metadata: { name: entry, kind: "manager_token" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reuse the env's secrets D1 if it exists, otherwise create it.
|
|
154
|
+
*
|
|
155
|
+
* "Exists" means *this project's* database: the name is `<project>-<env>-secrets`. Unscoped, the
|
|
156
|
+
* second project in an account would find the first's database by name and adopt it — two projects
|
|
157
|
+
* sharing one secrets store, each able to read and overwrite the other's rows.
|
|
158
|
+
*
|
|
159
|
+
* And "exists" also means *an account this project claims* (#378). An empty listing from an account
|
|
160
|
+
* nothing vouches for is not the absence this reads it as, and creating on it stands a live secrets
|
|
161
|
+
* database up in somebody else's account.
|
|
162
|
+
*/
|
|
163
|
+
async ensureDatabase(env: ManagedEnvironment): Promise<{ databaseId: string }> {
|
|
164
|
+
const name = managerWorkerName(this.#project, env);
|
|
165
|
+
const existing = await findOnConfirmedAccount({
|
|
166
|
+
...this.#account,
|
|
167
|
+
what: `the ${name} database`,
|
|
168
|
+
find: () => this.#cf.d1Provisioner().findDatabaseByName(name),
|
|
169
|
+
});
|
|
170
|
+
const db = existing ?? (await this.#cf.d1Provisioner().createDatabase(name));
|
|
171
|
+
return { databaseId: db.uuid };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Mint the env's master key only if absent — replacing it would orphan every stored secret.
|
|
176
|
+
*
|
|
177
|
+
* The entry name is project-scoped, and that is what makes "absent" mean *this project's* key is
|
|
178
|
+
* absent. Under the old flat name, a second project provisioning into the same account would find
|
|
179
|
+
* the first project's key already there, skip the mint, and encrypt its own rows under a key it does
|
|
180
|
+
* not own — silently coupling two projects until one of them tears down and orphans both.
|
|
181
|
+
*/
|
|
182
|
+
async ensureMasterKey(env: ManagedEnvironment): Promise<{ storeId: string }> {
|
|
183
|
+
const name = masterKeySecretName(this.#project, env);
|
|
184
|
+
const store = this.#cf.secrets(this.#storeId);
|
|
185
|
+
if (!(await store.exists(name))) {
|
|
186
|
+
await store.putSecret(name, JSON.stringify(await initialMasterKeyConfig()));
|
|
187
|
+
await this.#audit({
|
|
188
|
+
environment: env,
|
|
189
|
+
action: "secrets/set",
|
|
190
|
+
outcome: "success",
|
|
191
|
+
severity: "warning",
|
|
192
|
+
resourceType: "secret",
|
|
193
|
+
resourceId: name,
|
|
194
|
+
metadata: { name, kind: "master_key" },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return { storeId: this.#storeId };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Run the secrets migrations against the env's D1 over REST (idempotent — applied ones are skipped). */
|
|
201
|
+
async migrate(_env: ManagedEnvironment, databaseId: string): Promise<void> {
|
|
202
|
+
await runMigrations(this.#cf.d1(databaseId), secretsMigrationProvider());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Deploy the prebuilt manager worker for the environment. */
|
|
206
|
+
async deployManager(env: ManagedEnvironment, resolved: { databaseId: string; storeId: string }): Promise<void> {
|
|
207
|
+
await this.#deploy(env, resolved);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The directory of the prebuilt manager worker inside the installed `@pithy-sh/secrets` package. */
|
|
212
|
+
function managerDir(): string {
|
|
213
|
+
// Resolve through the package so it works installed (node_modules) or in the workspace; the
|
|
214
|
+
// `./src/*` export maps `worker` → `src/manager/worker.ts`, whose directory holds wrangler.jsonc.
|
|
215
|
+
return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/secrets/src/manager/worker")));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Write the scoped CF API token into the Secrets Store as the entry the manager binds at runtime.
|
|
220
|
+
* The value is the uniform versioned-value envelope (a one-entry envelope on first write), so the
|
|
221
|
+
* manager's `secretsStore` read decodes it exactly like every other secret. The token is `global` —
|
|
222
|
+
* one entry per project, written once and bound the same way by every one of that project's managers —
|
|
223
|
+
* so this is idempotent and re-runnable: `putSecret` upserts, and a re-deploy rewrites the same entry.
|
|
224
|
+
*/
|
|
225
|
+
export async function writeManagerCfApiToken(
|
|
226
|
+
cf: CloudflareClients,
|
|
227
|
+
target: { storeId: string; project: string },
|
|
228
|
+
apiToken: string,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
await cf
|
|
231
|
+
.secrets(target.storeId)
|
|
232
|
+
.putSecret(managerCfApiTokenSecretName(target.project), encodeVersionedValue(initialVersionedValue(apiToken)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Build the live deploy step. It resolves the manager's `wrangler.jsonc` template into a per-env
|
|
237
|
+
* standalone config (filling the placeholder ids), writes it beside the worker so wrangler's relative
|
|
238
|
+
* `main` resolves, then runs `wrangler deploy --config <resolved>`. The temp config is removed after.
|
|
239
|
+
*
|
|
240
|
+
* **Two distinct tokens, by design.** `apiToken` is the broad bootstrap token (`.dev.vars`
|
|
241
|
+
* `CLOUDFLARE_API_TOKEN`) that authenticates the deploy itself — it can create Workers, D1, and so on.
|
|
242
|
+
* The manager's **least-privilege** runtime token (scoped to Secrets Store Read + Write) is minted and
|
|
243
|
+
* written into the Secrets Store earlier, by `ensureManagerToken`, so the worker's `CLOUDFLARE_API_TOKEN`
|
|
244
|
+
* binding already resolves by deploy time. The broad token never reaches the worker; the minted token
|
|
245
|
+
* never deploys. Auth flows through env vars, not `wrangler login` (CLAUDE.md §CF token bootstrap).
|
|
246
|
+
*/
|
|
247
|
+
export function buildManagerDeploy(options: { accountId: string; apiToken: string; project: string }): DeployManager {
|
|
248
|
+
const { accountId, apiToken, project } = options;
|
|
249
|
+
return async (env, resolved) => {
|
|
250
|
+
const dir = managerDir();
|
|
251
|
+
const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as ManagerWranglerTemplate;
|
|
252
|
+
const config = resolveManagerConfig(template, { env, accountId, project, ...resolved });
|
|
253
|
+
const configPath = join(dir, `.wrangler.${env}.json`);
|
|
254
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
255
|
+
try {
|
|
256
|
+
await runWrangler(["deploy", "--config", configPath], {
|
|
257
|
+
cwd: dir,
|
|
258
|
+
env: { CLOUDFLARE_API_TOKEN: apiToken, CLOUDFLARE_ACCOUNT_ID: accountId },
|
|
259
|
+
});
|
|
260
|
+
} finally {
|
|
261
|
+
await unlink(configPath).catch(() => {});
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface CloudflareSecretsDeprovisionerOptions {
|
|
267
|
+
cf: CloudflareClients;
|
|
268
|
+
/**
|
|
269
|
+
* The project name (root `pithy.config.ts` `name`, via `requireProjectName`). Teardown recomputes
|
|
270
|
+
* every name it deletes, so this must be the same value provisioning used — a guessed one would
|
|
271
|
+
* either match nothing (a silent leak) or, worse, match another project's entries.
|
|
272
|
+
*/
|
|
273
|
+
project: string;
|
|
274
|
+
/** The CF Secrets Store id holding the per-env master keys. */
|
|
275
|
+
storeId: string;
|
|
276
|
+
/**
|
|
277
|
+
* The account this teardown deletes from, and what vouches for it (#378).
|
|
278
|
+
*
|
|
279
|
+
* Required, and required for the reason `CloudflareConfigOptions.account` is: the guard below reads a
|
|
280
|
+
* miss as "already gone", so against an account nothing claims it deletes nothing, audits nothing, and
|
|
281
|
+
* exits 0. A caller that has not decided which account it is tearing down cannot compile.
|
|
282
|
+
*/
|
|
283
|
+
account: ConfirmedAccount;
|
|
284
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
285
|
+
audit?: CliAuditEmit;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The live {@link SecretsDeprovisioner} — removes each environment's manager worker, (optionally) its
|
|
290
|
+
* master key, and its secrets D1, all through `@pithy-sh/cloudflare`. Every step is guarded so a
|
|
291
|
+
* missing resource is a no-op: teardown is idempotent and safe to re-run. The integration suite
|
|
292
|
+
* exercises the full provision → teardown round trip.
|
|
293
|
+
*/
|
|
294
|
+
export class CloudflareSecretsDeprovisioner implements SecretsDeprovisioner {
|
|
295
|
+
readonly #cf: CloudflareClients;
|
|
296
|
+
readonly #project: string;
|
|
297
|
+
readonly #storeId: string;
|
|
298
|
+
readonly #account: ConfirmedAccount;
|
|
299
|
+
readonly #audit: CliAuditEmit;
|
|
300
|
+
|
|
301
|
+
constructor(options: CloudflareSecretsDeprovisionerOptions) {
|
|
302
|
+
this.#cf = options.cf;
|
|
303
|
+
this.#project = options.project;
|
|
304
|
+
this.#storeId = options.storeId;
|
|
305
|
+
this.#account = options.account;
|
|
306
|
+
this.#audit = options.audit ?? (async () => {});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Delete the env's manager worker if it is deployed. Guarded, so teardown is idempotent — which is
|
|
311
|
+
* also why `project` must be the value provisioning used: a mismatch finds nothing, deletes nothing,
|
|
312
|
+
* and exits 0 while the real manager keeps running.
|
|
313
|
+
*
|
|
314
|
+
* A wrong *account* has the same three consequences and had no guard at all, so the lookup goes
|
|
315
|
+
* through `findOnConfirmedAccount` (#378): an empty listing is only an absence once something says
|
|
316
|
+
* whose account answered.
|
|
317
|
+
*/
|
|
318
|
+
async deleteManager(env: ManagedEnvironment): Promise<void> {
|
|
319
|
+
const name = managerWorkerName(this.#project, env);
|
|
320
|
+
if (
|
|
321
|
+
await findOnConfirmedAccount({
|
|
322
|
+
...this.#account,
|
|
323
|
+
what: `the ${name} Worker`,
|
|
324
|
+
find: () => this.#cf.workers().getWorker(name),
|
|
325
|
+
})
|
|
326
|
+
) {
|
|
327
|
+
await this.#cf.workers().deleteWorker(name);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Delete the env's master key if it is present — destructive, called only on a full destroy. The
|
|
333
|
+
* name is project-scoped, so this can only ever reach this project's key: another project's key in
|
|
334
|
+
* the same account-wide store is a different entry and is left readable.
|
|
335
|
+
*/
|
|
336
|
+
async deleteMasterKey(env: ManagedEnvironment): Promise<void> {
|
|
337
|
+
const name = masterKeySecretName(this.#project, env);
|
|
338
|
+
const store = this.#cf.secrets(this.#storeId);
|
|
339
|
+
if (await store.exists(name)) {
|
|
340
|
+
await store.deleteSecret(name);
|
|
341
|
+
// Deleting a master key orphans every secret it encrypted — this is the destructive step.
|
|
342
|
+
await this.#audit({
|
|
343
|
+
environment: env,
|
|
344
|
+
action: "secrets/removed",
|
|
345
|
+
outcome: "success",
|
|
346
|
+
severity: "warning",
|
|
347
|
+
resourceType: "secret",
|
|
348
|
+
resourceId: name,
|
|
349
|
+
metadata: { name, kind: "master_key" },
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Delete the env's secrets D1 if it exists. Project-scoped by name, like the manager above. */
|
|
355
|
+
async deleteDatabase(env: ManagedEnvironment): Promise<void> {
|
|
356
|
+
const db = await this.#cf.d1Provisioner().findDatabaseByName(managerWorkerName(this.#project, env));
|
|
357
|
+
if (db) {
|
|
358
|
+
await this.#cf.d1Provisioner().deleteDatabase(db.uuid);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Remove this project's manager token entirely — the inverse of `ensureManagerToken`. Delete the
|
|
364
|
+
* minted account token from Cloudflare (every same-named token, so a re-minted duplicate is swept
|
|
365
|
+
* too), then its Secrets Store entry. Both guarded: a missing token or entry is a no-op, so teardown
|
|
366
|
+
* is idempotent.
|
|
367
|
+
*
|
|
368
|
+
* `deleteTokensByName` is a name sweep over the whole account, so the project scope on the name is
|
|
369
|
+
* the containment: unscoped, one project's `pithy secrets deprovision` would revoke every other
|
|
370
|
+
* project's manager credential in the account and break all of their rotations at once.
|
|
371
|
+
*/
|
|
372
|
+
async deleteManagerToken(): Promise<void> {
|
|
373
|
+
await this.#cf.accountTokens().deleteTokensByName(managerCfApiTokenName(this.#project));
|
|
374
|
+
const entry = managerCfApiTokenSecretName(this.#project);
|
|
375
|
+
const store = this.#cf.secrets(this.#storeId);
|
|
376
|
+
if (await store.exists(entry)) {
|
|
377
|
+
await store.deleteSecret(entry);
|
|
378
|
+
await this.#audit({
|
|
379
|
+
environment: "global",
|
|
380
|
+
action: "secrets/removed",
|
|
381
|
+
outcome: "success",
|
|
382
|
+
severity: "warning",
|
|
383
|
+
resourceType: "secret",
|
|
384
|
+
resourceId: entry,
|
|
385
|
+
metadata: { name: entry, kind: "manager_token" },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|