@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,368 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { environmentScope } from "@pithy-sh/core/src/naming/provisionScope";
|
|
8
|
+
import { defineCommand } from "citty";
|
|
9
|
+
import { type CliAuditEmit, createCliAudit } from "../audit/cliAudit";
|
|
10
|
+
import { storeSecretMinter } from "../capabilities/mintSecrets";
|
|
11
|
+
import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
12
|
+
import { branchIdentity } from "../feature/identity";
|
|
13
|
+
import { provisionFeature } from "../feature/provision";
|
|
14
|
+
import {
|
|
15
|
+
loadProject,
|
|
16
|
+
loadProjectCloudflare,
|
|
17
|
+
loadProjectEnvironments,
|
|
18
|
+
projectCloudflareAccount,
|
|
19
|
+
requireProjectName,
|
|
20
|
+
} from "../project/config";
|
|
21
|
+
import { requireManagedEnvironment } from "../project/environment";
|
|
22
|
+
import { projectCapabilities, resolveWorkers } from "../project/workerScope";
|
|
23
|
+
import { assertProvisionConfirmed, provisionConfirmPhrase } from "../provision/confirm";
|
|
24
|
+
import { type ProvisionReport, provisionEnvironment } from "../provision/environment";
|
|
25
|
+
import { type ProvisionMode, requireProvisionMode } from "../provision/mode";
|
|
26
|
+
import { type PendingSecrets, pendingSecretLines, pendingSecrets } from "../provision/pendingSecrets";
|
|
27
|
+
import { AUDIT_DESTINATION_ENV, cloudflareProvisioners, type ResourceProvisioners } from "../provision/resources";
|
|
28
|
+
import { secretsStoreBindings, workerSecretRegistry } from "../provision/secretBindings";
|
|
29
|
+
import { cloudflareSecretsStore, type SecretsStore } from "../provision/store";
|
|
30
|
+
import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `pithy provision --env <name>` and `pithy provision --feature` — **one command, because provisioning is
|
|
34
|
+
* one job.**
|
|
35
|
+
*
|
|
36
|
+
* Both create an environment's Cloudflare resources, write their ids into each Worker's config, and
|
|
37
|
+
* migrate. They differ only in how the target environment is *named*: declared in the root
|
|
38
|
+
* `pithy.config.ts`, or derived from the checked-out branch. That is a flag, not a different verb.
|
|
39
|
+
*
|
|
40
|
+
* **The safety is in the scope, not in the spelling.** A `ProvisionScope` carries the resource naming and
|
|
41
|
+
* the `env.<name>` stanza the ids are written into, as one value (#240) — so a feature-named resource
|
|
42
|
+
* landing in a declared environment's stanza of a checked-in config is unexpressible rather than merely
|
|
43
|
+
* refused. Nothing about that depends on which words were typed, which is what leaves one command free to
|
|
44
|
+
* carry both modes.
|
|
45
|
+
*
|
|
46
|
+
* **The one real difference is persistence, and the command says so on every run.** `--env` writes
|
|
47
|
+
* `env.<name>` into the tracked `wrangler.jsonc`: long-lived ids a human reviews in a pull request.
|
|
48
|
+
* `--feature` writes a generated config under the already-ignored `.wrangler/`: one job's output,
|
|
49
|
+
* rebuilt every run and never committed. A single flag that flips whether output is committed will
|
|
50
|
+
* eventually surprise someone, so each run names the file it wrote and whether that file is committed,
|
|
51
|
+
* in the human summary and as `--json`'s `committed`. It is also what keeps the standing rule checkable
|
|
52
|
+
* rather than remembered — **a CI build process never commits back to the repository**: a pipeline runs
|
|
53
|
+
* `--feature` and has nothing to commit.
|
|
54
|
+
*
|
|
55
|
+
* **It is still its own command, and `deploy` refuses rather than calling it.** A deploy that silently
|
|
56
|
+
* creates account resources is hard to review. `pithy deploy --env staging` names this command instead of
|
|
57
|
+
* failing inside wrangler.
|
|
58
|
+
*
|
|
59
|
+
* **There is no `pithy deprovision`.** `pithy feature destroy` reverses a branch's environment because a
|
|
60
|
+
* branch's environment is disposable. Staging and production are not, and the one-word difference between
|
|
61
|
+
* the two is not a difference a flag should carry.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** Build the CF control-plane provisioners from the environment's credentials, or null when they are absent. */
|
|
65
|
+
function buildProvisioners(account: CloudflareAccountSelection | null): ResourceProvisioners | null {
|
|
66
|
+
const vars = cloudflareEnv({ account });
|
|
67
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
68
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
69
|
+
if (!accountId || !apiToken) return null;
|
|
70
|
+
// What vouches for that id travels with it (#378). `find` is find-or-create's first half, and an empty
|
|
71
|
+
// listing from an account nothing claims is not the absence the second half reads it as.
|
|
72
|
+
const confirmation = cloudflareAccountConfirmation({ account });
|
|
73
|
+
return cloudflareProvisioners(new CloudflareClients({ accountId, apiToken }), { accountId, confirmation });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The account's Secrets Store, or `null` when this project has recorded no store id.
|
|
78
|
+
*
|
|
79
|
+
* Absent is a degraded environment, never a failed command: a project composing no `secrets` capability
|
|
80
|
+
* needs no store, and one that does gets its `secrets_store_secrets` stanza — the binding `pithy add`
|
|
81
|
+
* deliberately could not write, and nothing came back for (#238).
|
|
82
|
+
*/
|
|
83
|
+
function buildStore(account: CloudflareAccountSelection | null): SecretsStore | null {
|
|
84
|
+
const vars = cloudflareEnv({ account });
|
|
85
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
86
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
87
|
+
const storeId = vars.SECRETS_STORE_ID ?? "";
|
|
88
|
+
if (!accountId || !apiToken || !storeId) return null;
|
|
89
|
+
return cloudflareSecretsStore(new CloudflareClients({ accountId, apiToken }), storeId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The audit emitter. Provisioning creates real infrastructure under a real token, and it runs headlessly
|
|
94
|
+
* in CI, so every creation leaves a record of what was made and under whose credentials.
|
|
95
|
+
*
|
|
96
|
+
* The trail lands in the project's own top-level database — the environment being provisioned may not
|
|
97
|
+
* have one yet, which is the whole point of the command.
|
|
98
|
+
*/
|
|
99
|
+
async function buildAudit(
|
|
100
|
+
projectDir: string,
|
|
101
|
+
capabilities: Capability[],
|
|
102
|
+
account: CloudflareAccountSelection | null,
|
|
103
|
+
): Promise<CliAuditEmit> {
|
|
104
|
+
const vars = cloudflareEnv({ account });
|
|
105
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
106
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
107
|
+
if (!accountId || !apiToken) return async () => {};
|
|
108
|
+
return createCliAudit({
|
|
109
|
+
projectDir,
|
|
110
|
+
// Routing, not truth: each event names the environment it acted on. Claiming this as `actedOn`
|
|
111
|
+
// would blame `dev` for a change to production — the regression `auditDestination.test.ts` pins.
|
|
112
|
+
env: AUDIT_DESTINATION_ENV,
|
|
113
|
+
capabilities,
|
|
114
|
+
clients: new CloudflareClients({ accountId, apiToken }),
|
|
115
|
+
apiToken,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The interactive confirm prompt for a production environment. Names what is about to happen first. */
|
|
120
|
+
function confirmPrompt(env: string): () => Promise<string> {
|
|
121
|
+
return async () => {
|
|
122
|
+
const { isCancel, text } = await import("@clack/prompts");
|
|
123
|
+
const answer = await text({
|
|
124
|
+
message: `This creates Cloudflare resources in ${env}. Type "${provisionConfirmPhrase(env)}" to confirm:`,
|
|
125
|
+
});
|
|
126
|
+
return isCancel(answer) ? "" : answer;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Refuse a run with no credentials, naming the environment it was for. */
|
|
131
|
+
function requireProvisioners(account: CloudflareAccountSelection | null, target: string): ResourceProvisioners {
|
|
132
|
+
const provisioners = buildProvisioners(account);
|
|
133
|
+
if (provisioners) return provisioners;
|
|
134
|
+
throw new ValidationError({
|
|
135
|
+
message: "Cloudflare credentials are missing.",
|
|
136
|
+
action: `Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to provision ${target}.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Everything `pithy provision` reads off the command line. */
|
|
141
|
+
export interface ProvisionRunOptions {
|
|
142
|
+
/**
|
|
143
|
+
* The project root. Defaults to the working directory.
|
|
144
|
+
*
|
|
145
|
+
* A seam, and one the refusal gate needs: `commands/provision.test.ts` proves the mode is refused
|
|
146
|
+
* before anything is read by pointing this at a directory that is not a project at all, which a test
|
|
147
|
+
* that had to `chdir` could not do without racing every other suite in the pool.
|
|
148
|
+
*/
|
|
149
|
+
projectDir?: string;
|
|
150
|
+
/** `--env <name>`: a declared environment. */
|
|
151
|
+
env?: string | undefined;
|
|
152
|
+
/** `--feature`: this branch's own environment. */
|
|
153
|
+
feature: boolean;
|
|
154
|
+
/** `--yes`. Required for a declared environment; never sufficient for production. */
|
|
155
|
+
yes: boolean;
|
|
156
|
+
/** `--confirm <phrase>`: the production phrase, for a headless run. */
|
|
157
|
+
confirm?: string | undefined;
|
|
158
|
+
/** `--seed`: also load fixtures. A feature environment is always seeded, so this adds nothing to it. */
|
|
159
|
+
seed: boolean;
|
|
160
|
+
/** `--json`. */
|
|
161
|
+
json: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One line per file written: what landed there, and what happens to it next. */
|
|
165
|
+
function describeConfigs(report: ProvisionReport): string[] {
|
|
166
|
+
return report.configs.map((config) => {
|
|
167
|
+
const what = config.ids === 0 ? config.path : `${config.ids} id${config.ids === 1 ? "" : "s"} into ${config.path}`;
|
|
168
|
+
// The whole point of the line: one flag decides whether these bytes are reviewed and kept, or thrown
|
|
169
|
+
// away and rebuilt. Saying which costs a sentence and saves someone committing a build artifact — or
|
|
170
|
+
// wondering why an id they were told to commit is not in `git status`.
|
|
171
|
+
const fate = report.committed
|
|
172
|
+
? `Commit ${config.ids > 1 ? "them" : "it"}.`
|
|
173
|
+
: "Ignored, and rebuilt on the next run.";
|
|
174
|
+
return `Wrote ${what}. ${fate}`;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* **What this command declares and cannot create**, and who can — which is not the same answer in both
|
|
180
|
+
* modes. See `provision/pendingSecrets.ts`, which holds the reasoning and the sentences.
|
|
181
|
+
*/
|
|
182
|
+
function deferredSecrets(capabilities: Capability[], mode: ProvisionMode): PendingSecrets {
|
|
183
|
+
return pendingSecrets(workerSecretRegistry(capabilities) ?? {}, mode);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Write the report: one JSON line, or the human summary. */
|
|
187
|
+
function writeReport(
|
|
188
|
+
report: ProvisionReport,
|
|
189
|
+
options: { json: boolean; seeded: boolean; pending: PendingSecrets },
|
|
190
|
+
): void {
|
|
191
|
+
if (options.json) {
|
|
192
|
+
process.stdout.write(
|
|
193
|
+
`${formatJsonLine({
|
|
194
|
+
command: "provision",
|
|
195
|
+
...report,
|
|
196
|
+
pendingSecrets: options.pending.names,
|
|
197
|
+
pendingSecretsRemedy: options.pending.remedy,
|
|
198
|
+
})}\n`,
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const resource of report.resources) {
|
|
203
|
+
process.stdout.write(`${resource.name}: ${resource.created ? "created" : "exists"}.\n`);
|
|
204
|
+
}
|
|
205
|
+
for (const worker of report.workers) {
|
|
206
|
+
process.stdout.write(`${worker.worker} deploys as ${worker.name}.\n`);
|
|
207
|
+
}
|
|
208
|
+
for (const service of report.services) {
|
|
209
|
+
process.stdout.write(`${service.binding} bound to ${service.service}.\n`);
|
|
210
|
+
}
|
|
211
|
+
for (const secret of report.secretBindings) {
|
|
212
|
+
// Three states, and the middle one is new (#321): created by this run, already there, or waiting on
|
|
213
|
+
// a human. The value never appears — what was made and where it went is the whole useful report.
|
|
214
|
+
if (secret.minted) {
|
|
215
|
+
process.stdout.write(`${secret.binding} created. Reads ${secret.entry}.\n`);
|
|
216
|
+
} else if (secret.bound) {
|
|
217
|
+
process.stdout.write(`${secret.binding} reads ${secret.entry}.\n`);
|
|
218
|
+
} else {
|
|
219
|
+
process.stdout.write(
|
|
220
|
+
`${secret.binding} has no store entry yet. Create it with pithy secrets create ${secret.binding}.\n`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const line of describeConfigs(report)) process.stdout.write(`${line}\n`);
|
|
225
|
+
process.stdout.write(`Provisioned ${report.env}. ${options.seeded ? "Migrated and seeded." : "Migrated."}\n`);
|
|
226
|
+
// Before `Done.`, because it is the part of the job this command did not do. See `pendingSecrets`.
|
|
227
|
+
for (const line of pendingSecretLines(options.pending)) process.stdout.write(`${line}\n`);
|
|
228
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `--env <name>`: an environment the project declares, whose ids are source.
|
|
233
|
+
*
|
|
234
|
+
* It takes the resolved {@link ProvisionMode} rather than a bare name, and hands it on to the deferred-
|
|
235
|
+
* secrets report. There is one producer of the mode — `requireProvisionMode`, in `runProvision` — so
|
|
236
|
+
* neither branch can report itself as the other, which is how `--feature` came to print `--env`'s
|
|
237
|
+
* remedy in the first place (#330).
|
|
238
|
+
*/
|
|
239
|
+
async function provisionDeclared(
|
|
240
|
+
projectDir: string,
|
|
241
|
+
mode: Extract<ProvisionMode, { kind: "environment" }>,
|
|
242
|
+
options: ProvisionRunOptions,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const env = mode.env;
|
|
245
|
+
const config = await loadProject(projectDir);
|
|
246
|
+
// The declaration decides what may be provisioned. `--env live` on a project that never declared
|
|
247
|
+
// `live` is refused here, naming the set it does have — rather than creating `<project>-live-db`
|
|
248
|
+
// that nothing else in the CLI would ever look for again. It is also what closes `--env feature`:
|
|
249
|
+
// that name is a legal stanza key and an illegal declaration, so no project can admit it.
|
|
250
|
+
const environment = requireManagedEnvironment(env, loadProjectEnvironments(config));
|
|
251
|
+
const interactive = !options.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
252
|
+
await assertProvisionConfirmed({
|
|
253
|
+
env: environment,
|
|
254
|
+
yes: options.yes,
|
|
255
|
+
json: options.json,
|
|
256
|
+
...(options.confirm !== undefined ? { confirmPhrase: options.confirm } : {}),
|
|
257
|
+
...(interactive ? { prompt: confirmPrompt(environment) } : {}),
|
|
258
|
+
...(config.seed?.productionEnvironments !== undefined
|
|
259
|
+
? { productionEnvironments: config.seed.productionEnvironments }
|
|
260
|
+
: {}),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const account = loadProjectCloudflare(config) ?? null;
|
|
264
|
+
const provisioners = requireProvisioners(account, environment);
|
|
265
|
+
const capabilities = projectCapabilities(await resolveWorkers({ projectDir }));
|
|
266
|
+
// The scope carries both the names and the stanza. There is no second argument to disagree with.
|
|
267
|
+
const scope = environmentScope(requireProjectName(config), environment);
|
|
268
|
+
const store = buildStore(account);
|
|
269
|
+
const audit = await buildAudit(projectDir, capabilities, account);
|
|
270
|
+
const report = await provisionEnvironment({
|
|
271
|
+
projectDir,
|
|
272
|
+
scope,
|
|
273
|
+
capabilities,
|
|
274
|
+
provisioners,
|
|
275
|
+
...(store
|
|
276
|
+
? {
|
|
277
|
+
secretBindings: async (workerCapabilities) =>
|
|
278
|
+
secretsStoreBindings({
|
|
279
|
+
// A Worker composing no secrets capability declares no secrets, and gets no stanza.
|
|
280
|
+
registry: workerSecretRegistry(workerCapabilities) ?? {},
|
|
281
|
+
scope,
|
|
282
|
+
storeId: store.storeId,
|
|
283
|
+
exists: (name) => store.exists(name),
|
|
284
|
+
// A declared secret whose value is arbitrary is created here rather than printed as
|
|
285
|
+
// homework (#321). Absence is checked first, so an existing value is never replaced.
|
|
286
|
+
mint: storeSecretMinter({ store, environment: scope.stanza, audit }),
|
|
287
|
+
}),
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
// Off unless asked. A declared environment already holds real rows; seeding one is `pithy seed`'s
|
|
291
|
+
// job, with its own gate, and it must not be something provisioning did on the way past.
|
|
292
|
+
seedData: options.seed,
|
|
293
|
+
audit,
|
|
294
|
+
});
|
|
295
|
+
writeReport(report, { json: options.json, seeded: options.seed, pending: deferredSecrets(capabilities, mode) });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `--feature`: this branch's own environment, whose ids are a build artifact.
|
|
300
|
+
*
|
|
301
|
+
* **No confirmation gate, and that is not an omission.** A feature environment is created per pull
|
|
302
|
+
* request and destroyed on merge; requiring the phrase that protects production would put it in every
|
|
303
|
+
* pipeline, which is exactly how a gate stops meaning anything. It also always seeds — a feature
|
|
304
|
+
* environment is created empty and useless without fixtures — so `--seed` has nothing to add to it.
|
|
305
|
+
*/
|
|
306
|
+
async function provisionBranch(
|
|
307
|
+
projectDir: string,
|
|
308
|
+
mode: Extract<ProvisionMode, { kind: "feature" }>,
|
|
309
|
+
options: ProvisionRunOptions,
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
const { identity, capabilities } = await branchIdentity(projectDir);
|
|
312
|
+
const account = await projectCloudflareAccount(projectDir);
|
|
313
|
+
const provisioners = requireProvisioners(account, "a feature environment");
|
|
314
|
+
const store = buildStore(account);
|
|
315
|
+
const report = await provisionFeature({
|
|
316
|
+
projectDir,
|
|
317
|
+
capabilities,
|
|
318
|
+
...(store ? { store } : {}),
|
|
319
|
+
identity,
|
|
320
|
+
provisioners,
|
|
321
|
+
audit: await buildAudit(projectDir, capabilities, account),
|
|
322
|
+
});
|
|
323
|
+
writeReport(report, { json: options.json, seeded: true, pending: deferredSecrets(capabilities, mode) });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The command body, exported so the mode gate can be tested against a directory that is not a project.
|
|
328
|
+
*
|
|
329
|
+
* Throws `PithyError`; the citty wrapper below is what reports and exits.
|
|
330
|
+
*/
|
|
331
|
+
export async function runProvision(options: ProvisionRunOptions): Promise<void> {
|
|
332
|
+
// First, before the working directory is read, before a config is loaded, and before any Cloudflare
|
|
333
|
+
// client exists. A run that named no environment or two is a mistake in the command line, and it gets
|
|
334
|
+
// an answer about the command line.
|
|
335
|
+
const mode = requireProvisionMode(options);
|
|
336
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
337
|
+
if (mode.kind === "feature") return provisionBranch(projectDir, mode, options);
|
|
338
|
+
return provisionDeclared(projectDir, mode, options);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export default defineCommand({
|
|
342
|
+
meta: {
|
|
343
|
+
name: "provision",
|
|
344
|
+
description: "Create an environment's own Cloudflare resources, wire them into each Worker, then migrate",
|
|
345
|
+
},
|
|
346
|
+
args: {
|
|
347
|
+
env: { type: "string", description: "The declared environment to provision" },
|
|
348
|
+
feature: { type: "boolean", default: false, description: "Provision this branch's own environment instead" },
|
|
349
|
+
yes: { type: "boolean", default: false, description: "Confirm that this creates real Cloudflare resources" },
|
|
350
|
+
confirm: {
|
|
351
|
+
type: "string",
|
|
352
|
+
description: 'Unlock a production environment non-interactively: "yes, i really want to provision <env>"',
|
|
353
|
+
},
|
|
354
|
+
seed: { type: "boolean", default: false, description: "With --env: also load seed fixtures once the schema is up" },
|
|
355
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
356
|
+
},
|
|
357
|
+
run: ({ args }) =>
|
|
358
|
+
withErrorReporting(args.json, () =>
|
|
359
|
+
runProvision({
|
|
360
|
+
env: args.env,
|
|
361
|
+
feature: args.feature,
|
|
362
|
+
yes: args.yes,
|
|
363
|
+
confirm: args.confirm,
|
|
364
|
+
seed: args.seed,
|
|
365
|
+
json: args.json,
|
|
366
|
+
}),
|
|
367
|
+
),
|
|
368
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { defineCommand } from "citty";
|
|
7
|
+
import { defaultRemoveSteps, removeCapability } from "../capabilities/remove";
|
|
8
|
+
import { loadProject, projectCloudflareAccount, requireProjectName } from "../project/config";
|
|
9
|
+
import { envArg, requireEnvironment } from "../project/environment";
|
|
10
|
+
import { formatDone, withErrorReporting } from "../terminal/output";
|
|
11
|
+
import { buildAudit, targetWorker } from "./add";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `remove` is the deliberate exception to the agent-drivable / `--json` convention: it is destructive,
|
|
15
|
+
* so it is human-only. `--json` fast-fails here — before anything is read or changed.
|
|
16
|
+
*/
|
|
17
|
+
export function rejectJson(json: boolean): void {
|
|
18
|
+
if (json) {
|
|
19
|
+
throw new ValidationError({
|
|
20
|
+
message: "pithy remove is a manual command. --json is not supported.",
|
|
21
|
+
action: "Run pithy remove <capability> at a terminal.",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `--drop` confirmation for an environment. `dev` asks a light yes/no; any non-dev env demands the
|
|
28
|
+
* exact typed phrase `drop <cap> from <env>` (the Cloudflare-dashboard delete pattern) — a mismatch or
|
|
29
|
+
* a cancel returns false and aborts with zero changes. There is no bypass flag.
|
|
30
|
+
*/
|
|
31
|
+
export function dropConfirm(capability: string, env: string): () => Promise<boolean> {
|
|
32
|
+
return async () => {
|
|
33
|
+
const clack = await import("@clack/prompts");
|
|
34
|
+
if (env === "dev") {
|
|
35
|
+
const answer = await clack.confirm({ message: `Drop ${capability}'s tables from dev? This deletes data.` });
|
|
36
|
+
return !clack.isCancel(answer) && answer === true;
|
|
37
|
+
}
|
|
38
|
+
const phrase = `drop ${capability} from ${env}`;
|
|
39
|
+
const typed = await clack.text({ message: `This deletes ${env} data. Type "${phrase}" to confirm:` });
|
|
40
|
+
return !clack.isCancel(typed) && typed === phrase;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default defineCommand({
|
|
45
|
+
meta: { name: "remove", description: "Remove a capability — the manual, interactive inverse of add" },
|
|
46
|
+
args: {
|
|
47
|
+
capability: { type: "positional", required: true, description: "Capability name, e.g. auth" },
|
|
48
|
+
worker: { type: "string", description: "Which worker to unwire it from (apps/<name>)" },
|
|
49
|
+
drop: {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
default: false,
|
|
52
|
+
description: "Also roll back the capability's migrations (drops its tables)",
|
|
53
|
+
},
|
|
54
|
+
env: envArg("With --drop, the environment whose tables to drop"),
|
|
55
|
+
json: { type: "boolean", default: false, description: "Not supported — remove is manual-only" },
|
|
56
|
+
},
|
|
57
|
+
// Errors always render as terminal problem/action lines: `remove` has no machine-readable surface.
|
|
58
|
+
run: ({ args }) =>
|
|
59
|
+
withErrorReporting(false, async () => {
|
|
60
|
+
rejectJson(args.json);
|
|
61
|
+
|
|
62
|
+
const projectDir = process.cwd();
|
|
63
|
+
const env = requireEnvironment(args.env);
|
|
64
|
+
|
|
65
|
+
// Which Worker to unwire. `remove` is human-only, so the prompt is available whenever a TTY is.
|
|
66
|
+
const target = await targetWorker({
|
|
67
|
+
projectDir,
|
|
68
|
+
interactive: Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY),
|
|
69
|
+
...(args.worker === undefined ? {} : { worker: args.worker }),
|
|
70
|
+
});
|
|
71
|
+
const capabilities = target.capabilities;
|
|
72
|
+
|
|
73
|
+
// `requireProjectName`, never `resolveProjectName`: a `--drop` reverses migrations against a live
|
|
74
|
+
// database, and the name is what that database's owner stamp is checked against. A guessed one
|
|
75
|
+
// (the alphabetically-first Worker, the directory basename) differs between checkouts, so it would
|
|
76
|
+
// either refuse this project's own database or claim another's. Resolved here, at the command edge,
|
|
77
|
+
// before anything is read or unwired — a nameless project is told to fix its config, not half-removed.
|
|
78
|
+
const project = requireProjectName(await loadProject(projectDir));
|
|
79
|
+
// Resolved once, at the command edge, and used for both the drop and the audit. A `--drop --env
|
|
80
|
+
// staging` reverses migrations against a live database; the account is what says whose (#234).
|
|
81
|
+
const account = await projectCloudflareAccount(projectDir);
|
|
82
|
+
|
|
83
|
+
const result = await removeCapability({
|
|
84
|
+
workerDir: target.dir,
|
|
85
|
+
capability: args.capability,
|
|
86
|
+
drop: args.drop ? { env, confirm: dropConfirm(args.capability, env) } : undefined,
|
|
87
|
+
steps: defaultRemoveSteps({
|
|
88
|
+
projectDir,
|
|
89
|
+
workerDir: target.dir,
|
|
90
|
+
loadCapabilities: async () => capabilities,
|
|
91
|
+
project,
|
|
92
|
+
account,
|
|
93
|
+
}),
|
|
94
|
+
// `--drop`'s env is the natural audit target when given; otherwise "dev", which is inert — a
|
|
95
|
+
// plain unwiring has no live environment, and the audit database is resolved from the project
|
|
96
|
+
// root, narrowed to the Worker being unwired.
|
|
97
|
+
audit: await buildAudit({
|
|
98
|
+
account,
|
|
99
|
+
projectDir,
|
|
100
|
+
worker: target.name,
|
|
101
|
+
env: args.drop ? env : "dev",
|
|
102
|
+
capabilities,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!result.present) {
|
|
107
|
+
process.stdout.write(`${args.capability} is not present in ${target.name}. Nothing to remove.\n`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (result.aborted) {
|
|
111
|
+
process.stdout.write("Aborted. Nothing changed.\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.stdout.write(`Removed ${args.capability} from ${target.name}.\n`);
|
|
116
|
+
if (result.dropped) {
|
|
117
|
+
const total = result.dropped.reduce((sum, run) => sum + run.results.length, 0);
|
|
118
|
+
process.stdout.write(`Dropped ${total} migration${total === 1 ? "" : "s"} from ${env}.\n`);
|
|
119
|
+
}
|
|
120
|
+
if (result.ejected) {
|
|
121
|
+
process.stdout.write(`Deleted ${relative(projectDir, join(target.dir, "capabilities", args.capability))}/.\n`);
|
|
122
|
+
} else if (result.packageManager) {
|
|
123
|
+
process.stdout.write(`Uninstalled @pithy-sh/${args.capability}.\n`);
|
|
124
|
+
} else if (result.keptLinked) {
|
|
125
|
+
// Nothing declared the package, so there is nothing to uninstall — and npm, asked anyway, prunes
|
|
126
|
+
// every linked sibling with it. Say what was left, so the link in node_modules is never a surprise.
|
|
127
|
+
process.stdout.write(`Kept @pithy-sh/${args.capability} — a linked checkout provides it.\n`);
|
|
128
|
+
} else if (result.keptFor.length > 0) {
|
|
129
|
+
// The wiring is gone from this Worker, but the package is one shared install — say so plainly,
|
|
130
|
+
// and name who still holds it, so the leftover dependency is never a surprise.
|
|
131
|
+
const holders = result.keptFor.join(", ");
|
|
132
|
+
const verb = result.keptFor.length === 1 ? "still wires it" : "still wire it";
|
|
133
|
+
process.stdout.write(`Kept @pithy-sh/${args.capability} installed — ${holders} ${verb}.\n`);
|
|
134
|
+
}
|
|
135
|
+
if (result.tablesRemain) {
|
|
136
|
+
// The down code is gone now, so there's no post-removal pithy command to reverse them — name
|
|
137
|
+
// the tables to drop by hand, and point at --drop for next time.
|
|
138
|
+
//
|
|
139
|
+
// Unless a sibling Worker still wires the capability: Workers sharing a binding name share one
|
|
140
|
+
// database, so those tables are live for the sibling. Telling someone to drop them by hand would
|
|
141
|
+
// be telling them to delete data another Worker is serving.
|
|
142
|
+
const shared = result.keptFor.length > 0;
|
|
143
|
+
process.stdout.write(
|
|
144
|
+
shared
|
|
145
|
+
? `${args.capability}'s D1 tables were left in place — ${result.keptFor.join(", ")} still ${result.keptFor.length === 1 ? "wires" : "wire"} it and ${result.keptFor.length === 1 ? "is" : "are"} using them. Don't drop them by hand.\n`
|
|
146
|
+
: `${args.capability}'s D1 tables were left in place — your data is safe, and a later pithy add ${args.capability} reuses them. To drop them, remove the pithy_${args.capability}_* tables by hand (pass --drop to reverse them during removal).\n`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
150
|
+
}),
|
|
151
|
+
});
|