@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,425 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
7
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import type { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
9
|
+
import { type EmailCapability, isEmailCapability } from "@pithy-sh/email/src/capability";
|
|
10
|
+
import { deprovisionEmail, provisionEmail } from "@pithy-sh/email/src/provision/provisionEmail";
|
|
11
|
+
import { type RenderTracking, renderEmail } from "@pithy-sh/email/src/templates/engine";
|
|
12
|
+
import { samplePayloads } from "@pithy-sh/email/src/templates/samples";
|
|
13
|
+
import { managerWorkerName } from "@pithy-sh/secrets/src/provision/resolveManagerConfig";
|
|
14
|
+
import type { ManagedEnvironment } from "@pithy-sh/secrets/src/scope";
|
|
15
|
+
import { defineCommand } from "citty";
|
|
16
|
+
import { parse } from "comment-json";
|
|
17
|
+
import { createProjectCliAudit } from "../audit/cliAudit";
|
|
18
|
+
import {
|
|
19
|
+
CloudflareEmailDeprovisioner,
|
|
20
|
+
CloudflareEmailProvisioner,
|
|
21
|
+
type EmailEnvResources,
|
|
22
|
+
} from "../capabilities/emailProvisioner";
|
|
23
|
+
import { type ConfirmedAccount, findOnConfirmedAccount } from "../cloudflare/accountAnswer";
|
|
24
|
+
import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
25
|
+
import {
|
|
26
|
+
loadProject,
|
|
27
|
+
loadProjectEnvironments,
|
|
28
|
+
loadWorkerConfig,
|
|
29
|
+
loadWorkerDomains,
|
|
30
|
+
projectCloudflareAccount,
|
|
31
|
+
requireProjectName,
|
|
32
|
+
} from "../project/config";
|
|
33
|
+
import { resolveWorkerAddress } from "../project/workerAddress";
|
|
34
|
+
import {
|
|
35
|
+
composedProjectCapabilities,
|
|
36
|
+
type ResolvedWorker,
|
|
37
|
+
resolveSingleWorker,
|
|
38
|
+
resolveWorkers,
|
|
39
|
+
} from "../project/workerScope";
|
|
40
|
+
import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The audit emitter for an email command. Provisioning spans every managed environment at once, so
|
|
44
|
+
* there is no single target env to key the audit database on — `"dev"` is the fallback (mirrors
|
|
45
|
+
* `pithy feature`'s convention for env-spanning commands). A no-op when creds or the audit capability
|
|
46
|
+
* aren't there.
|
|
47
|
+
*/
|
|
48
|
+
async function buildAudit(projectDir: string, accountId: string, apiToken: string) {
|
|
49
|
+
// `env` selects the audit database only, and defaults to `dev`: this command spans environments, so no
|
|
50
|
+
// single value is true for the run; each event states the environment it acted on.
|
|
51
|
+
return createProjectCliAudit({ projectDir, accountId, apiToken });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load the composed email capability. Capabilities live in each Worker's `apps/<name>/pithy.config.ts`,
|
|
56
|
+
* and the config is one brand identity for the project, so the first Worker composing `email` provides
|
|
57
|
+
* it.
|
|
58
|
+
*
|
|
59
|
+
* The **capability**, not its `emailConfig`, because two of the three things a provision run stamps into
|
|
60
|
+
* the host's vars now come from it: the theme off `emailConfig`, and the catalogs off `hostCatalogs()`,
|
|
61
|
+
* which only answers correctly once `compose` has run. Returning the resolved config alone left the
|
|
62
|
+
* catalogs unreachable, and the `EMAIL_MESSAGES` var unwritten (pithy-sh/pithy#441).
|
|
63
|
+
*
|
|
64
|
+
* **The project's whole capability surface, assembled, and that is deliberate rather than a shortcut
|
|
65
|
+
* past per-Worker composition.** The Worker this stamps is `<project>-<env>-email`: it composes
|
|
66
|
+
* nothing, it is deployed once for the project, and it sends every Worker's mail. So the catalogs it
|
|
67
|
+
* carries are the project's, not one app Worker's — a project where `apps/web` composes `i18n()` and
|
|
68
|
+
* `apps/api` composes `email()` gets a host that speaks Spanish, which is the only useful answer for a
|
|
69
|
+
* Worker that belongs to neither. `capabilities/compose.ts` states the rule once for all three CLI
|
|
70
|
+
* sites that resolve a host.
|
|
71
|
+
*
|
|
72
|
+
* Exported for `capabilities/emailHostCatalogs.test.ts`, which is what proves this command reads a *composed*
|
|
73
|
+
* capability rather than a freshly built one — the wiring existed and nothing held the command to it.
|
|
74
|
+
* `resolve` is the filesystem seam that test drives it through.
|
|
75
|
+
*/
|
|
76
|
+
export async function loadEmailCapability(
|
|
77
|
+
projectDir: string,
|
|
78
|
+
resolve: (options: { projectDir: string }) => Promise<ResolvedWorker[]> = resolveWorkers,
|
|
79
|
+
): Promise<EmailCapability> {
|
|
80
|
+
// A hook that refuses here refuses for the same reason the Worker would refuse to boot, which is
|
|
81
|
+
// worth knowing before a host is deployed for it rather than after.
|
|
82
|
+
const capabilities = await resolve({ projectDir }).then(composedProjectCapabilities);
|
|
83
|
+
const cap = capabilities.find(isEmailCapability);
|
|
84
|
+
if (!cap) {
|
|
85
|
+
throw new ValidationError({
|
|
86
|
+
message: "The email capability is not configured.",
|
|
87
|
+
action: "Add `email({ ... })` to a worker's pithy.config.ts (run `pithy add email`).",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return cap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The Cloudflare credentials this command provisions with, for **the account the project belongs to**.
|
|
95
|
+
*
|
|
96
|
+
* The account is a parameter rather than an ambient, so this cannot resolve before something has
|
|
97
|
+
* established which account the project is for (#206).
|
|
98
|
+
*
|
|
99
|
+
* It also carries **what vouches for the account** (#378). A bare id is what every destructive and
|
|
100
|
+
* creative site here used to hold, and an id alone cannot tell "this account has no such Worker" from
|
|
101
|
+
* "I asked an account nothing claims" — the two arrive as one empty listing.
|
|
102
|
+
*/
|
|
103
|
+
function loadCloudflareCreds(account: CloudflareAccountSelection | null): {
|
|
104
|
+
account: ConfirmedAccount;
|
|
105
|
+
accountId: string;
|
|
106
|
+
apiToken: string;
|
|
107
|
+
storeId: string;
|
|
108
|
+
} {
|
|
109
|
+
const vars = cloudflareEnv({ account });
|
|
110
|
+
const confirmation = cloudflareAccountConfirmation({ account });
|
|
111
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
112
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
113
|
+
const storeId = vars.SECRETS_STORE_ID ?? "";
|
|
114
|
+
if (!accountId || !apiToken) {
|
|
115
|
+
throw new ValidationError({
|
|
116
|
+
message: "Cloudflare credentials are missing.",
|
|
117
|
+
action: "Run pithy init to record CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or export them.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (!storeId) {
|
|
121
|
+
throw new ValidationError({
|
|
122
|
+
message: "The CF Secrets Store id is missing.",
|
|
123
|
+
action: "Run pithy add secrets to record SECRETS_STORE_ID (the email worker decrypts its signing key from it).",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { account: { accountId, confirmation }, accountId, apiToken, storeId };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A wrangler env stanza — only the fields the email worker deploy reads from the project's config. */
|
|
130
|
+
interface WranglerStanza {
|
|
131
|
+
d1_databases?: { binding: string; database_id?: string }[];
|
|
132
|
+
vars?: Record<string, string>;
|
|
133
|
+
env?: Record<string, WranglerStanza | undefined>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the per-environment resources the email worker binds, from the **app Worker's** `wrangler.jsonc`
|
|
138
|
+
* (the app `DB` id and `BASE_URL` per env) and a live lookup of the env's secrets database. Each missing
|
|
139
|
+
* value throws an actionable error rather than deploying a half-wired worker.
|
|
140
|
+
*
|
|
141
|
+
* Which Worker is the app Worker? Every Worker owns its own `wrangler.jsonc`, so a project with several
|
|
142
|
+
* names one with `--worker`; one Worker needs no ceremony. `BASE_URL` in particular is that Worker's public
|
|
143
|
+
* URL — the address a tracked link resolves back to — so it is not something to guess.
|
|
144
|
+
*/
|
|
145
|
+
function buildResolveEnv(
|
|
146
|
+
worker: ResolvedWorker,
|
|
147
|
+
cf: CloudflareClients,
|
|
148
|
+
/**
|
|
149
|
+
* The project name the secrets database is found by — `<project>-<env>-secrets`. Resolved once by the
|
|
150
|
+
* caller via `requireProjectName`, never guessed: the lookup is by name, so a wrong one either reports
|
|
151
|
+
* a database that "does not exist" or binds another project's secrets store.
|
|
152
|
+
*/
|
|
153
|
+
project: string,
|
|
154
|
+
/**
|
|
155
|
+
* The account the secrets database is looked for on, and what vouches for it (#378).
|
|
156
|
+
*
|
|
157
|
+
* The refusal below reads a missing database as "provision it first". Against an account nothing
|
|
158
|
+
* claims, that database is missing because this run asked the wrong account — and the sentence sends
|
|
159
|
+
* an operator to run a provisioning command they have already run.
|
|
160
|
+
*/
|
|
161
|
+
account: ConfirmedAccount,
|
|
162
|
+
): (env: ManagedEnvironment) => Promise<EmailEnvResources> {
|
|
163
|
+
return async (env) => {
|
|
164
|
+
const path = join(worker.dir, "wrangler.jsonc");
|
|
165
|
+
const config = parse(await readFile(path, "utf8")) as unknown as WranglerStanza;
|
|
166
|
+
const stanza = config.env?.[env];
|
|
167
|
+
if (!stanza) {
|
|
168
|
+
throw new ValidationError({
|
|
169
|
+
message: `${worker.name}'s wrangler.jsonc has no env.${env} stanza.`,
|
|
170
|
+
action: `Add the ${env} environment to ${path} with its DB binding and a BASE_URL var.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const appDatabaseId = stanza.d1_databases?.find((db) => db.binding === "DB")?.database_id;
|
|
174
|
+
if (!appDatabaseId) {
|
|
175
|
+
throw new ValidationError({
|
|
176
|
+
message: `${worker.name}'s wrangler.jsonc env.${env} has no DB database_id.`,
|
|
177
|
+
action: `Provision the ${env} app database and set its id on the DB binding.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// Through the one resolver, which prefers the `domains` declaration and falls back to the route and
|
|
181
|
+
// then to this same var — so an adopter who set it by hand still works, and one who declared a domain
|
|
182
|
+
// is not told to set a var that would only duplicate it. This used to read `vars.BASE_URL` directly
|
|
183
|
+
// with no validation at all, passing whatever string was there through to the deployed email Worker.
|
|
184
|
+
let domains: WorkerDomains | undefined;
|
|
185
|
+
try {
|
|
186
|
+
domains = loadWorkerDomains(await loadWorkerConfig(worker.dir));
|
|
187
|
+
} catch {
|
|
188
|
+
// A malformed declaration must not block provisioning off a good route or var; `pithy env` and
|
|
189
|
+
// `pithy deploy` are where it gets reported.
|
|
190
|
+
domains = undefined;
|
|
191
|
+
}
|
|
192
|
+
const address = resolveWorkerAddress({ environment: env, domains, stanza });
|
|
193
|
+
if (!address) {
|
|
194
|
+
throw new ValidationError({
|
|
195
|
+
message: `${worker.name} has no ${env} address.`,
|
|
196
|
+
action: `Declare it in the Worker's pithy.config.ts — \`domains: { ${env}: { pattern: "…", zone: "…" } }\`. Tracking and unsubscribe links are built against it.`,
|
|
197
|
+
detail: `no domains declaration, route, or vars.BASE_URL resolved for env.${env} in ${path}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const baseUrl = address.url;
|
|
201
|
+
const secretsDb = await findOnConfirmedAccount({
|
|
202
|
+
...account,
|
|
203
|
+
what: `the ${managerWorkerName(project, env)} database`,
|
|
204
|
+
find: () => cf.d1Provisioner().findDatabaseByName(managerWorkerName(project, env)),
|
|
205
|
+
});
|
|
206
|
+
if (!secretsDb) {
|
|
207
|
+
throw new ValidationError({
|
|
208
|
+
message: `The ${env} secrets database (${managerWorkerName(project, env)}) does not exist.`,
|
|
209
|
+
action: "Run `pithy secrets provision` first — the email worker reads its signing key from it.",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return { appDatabaseId, secretsDatabaseId: secretsDb.uuid, baseUrl };
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The three routing flags, in the order the refusal names them. */
|
|
217
|
+
const ROUTING_FLAGS = ["--routing-zone", "--inbound-address", "--app-worker"] as const;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The three routing flags, **together or not at all**.
|
|
221
|
+
*
|
|
222
|
+
* Routing is opt-in: the inbound address, its zone, and the worker that answers are an operator choice
|
|
223
|
+
* (and must avoid the apex MX), so none of them is derived and passing none is an ordinary run.
|
|
224
|
+
*
|
|
225
|
+
* A *partial* set is a different thing, and it used to be treated as the same thing — two flags of
|
|
226
|
+
* three wired nothing, said nothing, and exited 0. The consequence is invisible until somebody replies
|
|
227
|
+
* to a message and the mail goes nowhere, by which time nobody is looking at this command. A
|
|
228
|
+
* provisioning command that half-configures a mail path and reports success is worse than one that
|
|
229
|
+
* refuses.
|
|
230
|
+
*
|
|
231
|
+
* `pithy support` makes this decision already (`commands/support.ts` `resolveRouting`); this is the
|
|
232
|
+
* sibling's rule, with the missing flags named — that is the only thing the operator has to type next.
|
|
233
|
+
*/
|
|
234
|
+
export function resolveRouting(
|
|
235
|
+
zoneId: string | undefined,
|
|
236
|
+
address: string | undefined,
|
|
237
|
+
appWorkerName: string | undefined,
|
|
238
|
+
): { zoneId: string; address: string; appWorkerName: string } | undefined {
|
|
239
|
+
if (zoneId && address && appWorkerName) return { zoneId, address, appWorkerName };
|
|
240
|
+
if (!zoneId && !address && !appWorkerName) return undefined;
|
|
241
|
+
const missing = [zoneId, address, appWorkerName]
|
|
242
|
+
.map((value, index) => (value ? undefined : ROUTING_FLAGS[index]))
|
|
243
|
+
.filter((flag): flag is (typeof ROUTING_FLAGS)[number] => flag !== undefined);
|
|
244
|
+
throw new ValidationError({
|
|
245
|
+
message: "The inbound routing options are incomplete.",
|
|
246
|
+
action: `Also pass ${missing.join(" and ")}, or none of the three. Inbound mail is routed only when ${ROUTING_FLAGS.join(", ")} are given together.`,
|
|
247
|
+
detail: `routing flags given: ${ROUTING_FLAGS.filter((flag) => !missing.includes(flag)).join(", ")}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const provision = defineCommand({
|
|
252
|
+
meta: { name: "provision", description: "Provision the shared suppression DB and per-environment email workers" },
|
|
253
|
+
args: {
|
|
254
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
255
|
+
worker: {
|
|
256
|
+
type: "string",
|
|
257
|
+
description:
|
|
258
|
+
"The app worker whose wrangler.jsonc carries the per-environment DB binding and BASE_URL (default: the project's only worker)",
|
|
259
|
+
},
|
|
260
|
+
"routing-zone": {
|
|
261
|
+
type: "string",
|
|
262
|
+
description:
|
|
263
|
+
"Cloudflare Zone ID of the (sub)domain receiving the mail — Email Routing must already be enabled on it (its MX points to Cloudflare). Use a subdomain zone (e.g. bounce.example.com), never your apex, so your primary MX is untouched. Find it on the zone's Overview page.",
|
|
264
|
+
},
|
|
265
|
+
"inbound-address": {
|
|
266
|
+
type: "string",
|
|
267
|
+
description:
|
|
268
|
+
"The exact recipient address the rule matches (e.g. bounce@bounce.example.com); mail sent to it is delivered to the app worker's email() handler.",
|
|
269
|
+
},
|
|
270
|
+
"app-worker": {
|
|
271
|
+
type: "string",
|
|
272
|
+
description:
|
|
273
|
+
"Deployed name of your production app worker — the one running createEntrypoint with the email() bounce handler (e.g. pithy-app-prod).",
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
run: ({ args }) =>
|
|
277
|
+
withErrorReporting(args.json, async () => {
|
|
278
|
+
const projectDir = process.cwd();
|
|
279
|
+
// First, before the project name, the credentials, and every Cloudflare call: a mistyped or
|
|
280
|
+
// half-remembered flag set must cost nothing and must not provision anything.
|
|
281
|
+
const routing = resolveRouting(args["routing-zone"], args["inbound-address"], args["app-worker"]);
|
|
282
|
+
// The leading segment of the suppression database, the email workers, and the bounce rule. The
|
|
283
|
+
// database is found by name and reused, so `requireProjectName` refuses to guess — a guessed name
|
|
284
|
+
// adopts another project's opt-out list (docs/NAMING.md).
|
|
285
|
+
const config = await loadProject(projectDir);
|
|
286
|
+
const project = requireProjectName(config);
|
|
287
|
+
// The project's own environment set (#241): what this command fans out across, rather than a
|
|
288
|
+
// pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
|
|
289
|
+
const environments = loadProjectEnvironments(config);
|
|
290
|
+
const { account, accountId, apiToken, storeId } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
291
|
+
const emailCapability = await loadEmailCapability(projectDir);
|
|
292
|
+
const appWorker = await resolveSingleWorker({
|
|
293
|
+
projectDir,
|
|
294
|
+
...(args.worker !== undefined ? { worker: args.worker } : {}),
|
|
295
|
+
});
|
|
296
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
297
|
+
const provisioner = new CloudflareEmailProvisioner({
|
|
298
|
+
cf,
|
|
299
|
+
project,
|
|
300
|
+
account,
|
|
301
|
+
apiToken,
|
|
302
|
+
storeId,
|
|
303
|
+
theme: emailCapability.emailConfig.theme,
|
|
304
|
+
// Read now, after `loadEmailCapability` ran every capability's `compose` hook — the adopter's
|
|
305
|
+
// overrides and the kit's translations, flattened for the standalone host. Read before that,
|
|
306
|
+
// this is `{}` and the host is deployed English-only.
|
|
307
|
+
messages: emailCapability.hostCatalogs(),
|
|
308
|
+
resolveEnv: buildResolveEnv(appWorker, cf, project, account),
|
|
309
|
+
routing,
|
|
310
|
+
audit: await buildAudit(projectDir, accountId, apiToken),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const result = await provisionEmail(provisioner, environments);
|
|
314
|
+
|
|
315
|
+
if (args.json) {
|
|
316
|
+
process.stdout.write(`${formatJsonLine({ command: "email provision", ...result })}\n`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
process.stdout.write(`Suppression database and ${result.environments.length} email workers ready.\n`);
|
|
320
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
321
|
+
}),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const deprovision = defineCommand({
|
|
325
|
+
meta: { name: "deprovision", description: "Remove the email workers (and optionally the suppression DB)" },
|
|
326
|
+
args: {
|
|
327
|
+
suppression: {
|
|
328
|
+
type: "boolean",
|
|
329
|
+
default: false,
|
|
330
|
+
description: "Also delete this project's suppression DB (irreversible)",
|
|
331
|
+
},
|
|
332
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
333
|
+
},
|
|
334
|
+
run: ({ args }) =>
|
|
335
|
+
withErrorReporting(args.json, async () => {
|
|
336
|
+
const projectDir = process.cwd();
|
|
337
|
+
// Teardown finds resources by recomputing their names, so this must be the same name
|
|
338
|
+
// `provision` used. A guess would match nothing, delete nothing, and still exit 0.
|
|
339
|
+
const config = await loadProject(projectDir);
|
|
340
|
+
const project = requireProjectName(config);
|
|
341
|
+
// The project's own environment set (#241): what this command fans out across, rather than a
|
|
342
|
+
// pair the CLI assumed. A project declaring `live` gets `live` provisioned and torn down too.
|
|
343
|
+
const environments = loadProjectEnvironments(config);
|
|
344
|
+
const { account, accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
345
|
+
const cf = new CloudflareClients({ accountId, apiToken });
|
|
346
|
+
const deprovisioner = new CloudflareEmailDeprovisioner({
|
|
347
|
+
account,
|
|
348
|
+
cf,
|
|
349
|
+
project,
|
|
350
|
+
audit: await buildAudit(projectDir, accountId, apiToken),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await deprovisionEmail(deprovisioner, environments, { deleteSuppression: args.suppression });
|
|
354
|
+
|
|
355
|
+
if (args.json) {
|
|
356
|
+
process.stdout.write(
|
|
357
|
+
`${formatJsonLine({ command: "email deprovision", suppressionDeleted: args.suppression })}\n`,
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
process.stdout.write(`Email workers removed${args.suppression ? ", including the suppression database" : ""}.\n`);
|
|
362
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const test = defineCommand({
|
|
367
|
+
meta: { name: "test", description: "Render one template through your config and send it to an address" },
|
|
368
|
+
args: {
|
|
369
|
+
to: { type: "string", description: "Recipient address", required: true },
|
|
370
|
+
template: { type: "string", default: "welcome", description: "Template id (e.g. magicLink, newsletter)" },
|
|
371
|
+
from: { type: "string", description: "Override the configured from address" },
|
|
372
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
373
|
+
},
|
|
374
|
+
run: ({ args }) =>
|
|
375
|
+
withErrorReporting(args.json, async () => {
|
|
376
|
+
const projectDir = process.cwd();
|
|
377
|
+
const { accountId, apiToken } = loadCloudflareCreds(await projectCloudflareAccount(projectDir));
|
|
378
|
+
const { fromAddress, fromName, baseUrl, theme } = (await loadEmailCapability(projectDir)).emailConfig;
|
|
379
|
+
const payload = samplePayloads[args.template];
|
|
380
|
+
if (!payload) {
|
|
381
|
+
throw new ValidationError({
|
|
382
|
+
message: `No sample payload for template "${args.template}".`,
|
|
383
|
+
action: `Known templates: ${Object.keys(samplePayloads).join(", ")}.`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A throwaway tracking context so any template (incl. marketing, which forces an unsubscribe link)
|
|
388
|
+
// renders. Links are not actually tracked — this is a visual/delivery check of the config.
|
|
389
|
+
const tracking: RenderTracking = {
|
|
390
|
+
baseUrl,
|
|
391
|
+
jobId: "test",
|
|
392
|
+
recipient: args.to,
|
|
393
|
+
key: "email-test-preview-key",
|
|
394
|
+
kid: "test",
|
|
395
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
396
|
+
openTracking: false,
|
|
397
|
+
clickTracking: false,
|
|
398
|
+
};
|
|
399
|
+
const rendered = await renderEmail(args.template, payload, theme, tracking);
|
|
400
|
+
const from = args.from ?? fromAddress;
|
|
401
|
+
const result = await new CloudflareClients({ accountId, apiToken }).email().send({
|
|
402
|
+
to: args.to,
|
|
403
|
+
from: { email: from, name: fromName },
|
|
404
|
+
subject: rendered.subject,
|
|
405
|
+
html: rendered.html,
|
|
406
|
+
text: rendered.text,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
if (args.json) {
|
|
410
|
+
process.stdout.write(
|
|
411
|
+
`${formatJsonLine({ command: "email test", template: args.template, to: args.to, from, messageId: result.messageId })}\n`,
|
|
412
|
+
);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
process.stdout.write(
|
|
416
|
+
`Sent "${args.template}" from ${from} to ${args.to}${result.messageId ? ` (${result.messageId})` : ""}.\n`,
|
|
417
|
+
);
|
|
418
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
export default defineCommand({
|
|
423
|
+
meta: { name: "email", description: "Provision and manage the email infrastructure" },
|
|
424
|
+
subCommands: { provision, deprovision, test },
|
|
425
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { projectCloudflareAccount } from "../project/config";
|
|
6
|
+
import { buildEnvInventory, type EnvInventory, type EnvResource } from "../project/envInventory";
|
|
7
|
+
import { formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
8
|
+
import { dim, link, supportsHyperlinks } from "../terminal/style";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `pithy env [name] [--worker <name>] [--json]` — a read-only inventory of every environment every
|
|
12
|
+
* Worker declares in its own `wrangler.jsonc`: its bindings, resolved ids, provisioned state, base
|
|
13
|
+
* URL, and Cloudflare dashboard links. It writes nothing, switches nothing, and always exits 0 —
|
|
14
|
+
* provisioning health and non-zero exits belong to `pithy doctor`. A resource id renders as a
|
|
15
|
+
* clickable OSC-8 hyperlink to its dashboard page in a terminal, falling back to a printed URL when
|
|
16
|
+
* hyperlinks are off (piped output / NO_COLOR); `--json` always carries the full `dashboardUrl` per
|
|
17
|
+
* resource. The positional `name` narrows to one environment across every Worker; `--worker` narrows
|
|
18
|
+
* to one Worker.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A short parenthetical label for a resource kind. */
|
|
22
|
+
const KIND_LABEL: Record<EnvResource["kind"], string> = {
|
|
23
|
+
d1: "d1",
|
|
24
|
+
kv: "kv",
|
|
25
|
+
r2: "r2",
|
|
26
|
+
durable_object: "do",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What an unprovisioned binding reads as in a **deployed** environment: an action item, and the reason
|
|
31
|
+
* `pithy env` is read before and after provisioning.
|
|
32
|
+
*/
|
|
33
|
+
const NOT_PROVISIONED = "not provisioned";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What an unprovisioned binding reads as in a **local** environment: a statement, not an action item.
|
|
37
|
+
*
|
|
38
|
+
* **A check that cannot fail meaningfully for an environment should not be run against it.** There is
|
|
39
|
+
* no remote `dash-dev-db` and there is not supposed to be one — Miniflare serves D1 from the binding
|
|
40
|
+
* declaration, with state under `.wrangler/state/v3/d1`, and `pithy dev` works precisely because no
|
|
41
|
+
* Cloudflare resource is involved. Reporting three such bindings as deficiencies taught an operator to
|
|
42
|
+
* skim past red in the one command they read against production (#320).
|
|
43
|
+
*
|
|
44
|
+
* Deliberately not the same string as the deployed one. Sharing it is what made the real action item
|
|
45
|
+
* weaker.
|
|
46
|
+
*/
|
|
47
|
+
const LOCAL = "local";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The half `local` does not cover, said once per stanza rather than once per binding.
|
|
51
|
+
*
|
|
52
|
+
* **The top-level stanza is the local environment and the stanza a bare `pithy deploy` ships.** `wrangler
|
|
53
|
+
* deploy` with no `--env` takes it to Cloudflare, and it is the one deploy path with no
|
|
54
|
+
* `assertEnvironmentProvisioned` in front of it — every `--env` deploy is refused before a binding with
|
|
55
|
+
* no id reaches wrangler (#240), and this one is not. So `local` is a true statement about Miniflare and
|
|
56
|
+
* an incomplete one about the file: an operator reading `pithy env` before a deploy read it as *nothing
|
|
57
|
+
* to provision here* (#320 gave the word, this gives back what it took).
|
|
58
|
+
*
|
|
59
|
+
* A property of the stanza, so it is printed under the stanza, and only where it is a fact — a local
|
|
60
|
+
* environment whose bindings all carry ids has nothing ungated about it, and a deployed one already says
|
|
61
|
+
* `not provisioned`, which is the action item. A line under every environment would be the wallpaper the
|
|
62
|
+
* word was introduced to remove.
|
|
63
|
+
*/
|
|
64
|
+
const UNGATED = "Miniflare needs no id. A bare pithy deploy ships this stanza, and nothing gates it on one.";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render an id-carrying value: the resource's state when it has no id, a clickable link when
|
|
68
|
+
* hyperlinks render and a dashboard URL exists, the plain id + printed URL as the fallback, or the
|
|
69
|
+
* bare id when there is no URL (no account id, or an unlinkable kind).
|
|
70
|
+
*
|
|
71
|
+
* `local` is the environment's own answer, carried on the report rather than guessed from its name — a
|
|
72
|
+
* local environment that *does* name a real id still shows it, because pointing dev at a remote
|
|
73
|
+
* database is a thing an adopter may do and the id is the true and useful thing to print.
|
|
74
|
+
*/
|
|
75
|
+
function renderValue(id: string | null, provisioned: boolean, url: string | null, local: boolean): string {
|
|
76
|
+
if (!provisioned) return dim(local ? LOCAL : NOT_PROVISIONED);
|
|
77
|
+
const text = id ?? "";
|
|
78
|
+
if (!url) return text;
|
|
79
|
+
if (supportsHyperlinks()) return link(url, text);
|
|
80
|
+
return `${text} ${dim(url)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render the full human-readable inventory as a single string, optionally filtered to one environment.
|
|
85
|
+
* Workers are the outer grouping — each Worker's name, then its environments indented beneath, then
|
|
86
|
+
* each environment's bindings. A Worker with no matching environment is reported as such, not omitted.
|
|
87
|
+
*/
|
|
88
|
+
export function renderEnvInventory(inventory: EnvInventory, filter?: string): string {
|
|
89
|
+
return renderLines(inventory, filter).join("\n").concat("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The rendered lines, before they are joined — the seam a gate reads so it can assert per line. */
|
|
93
|
+
export function renderLines(inventory: EnvInventory, filter?: string): string[] {
|
|
94
|
+
const lines: string[] = [];
|
|
95
|
+
if (!inventory.accountId) lines.push(dim("No CLOUDFLARE_ACCOUNT_ID. Dashboard links omitted."));
|
|
96
|
+
|
|
97
|
+
for (const worker of inventory.workers) {
|
|
98
|
+
lines.push(`${worker.worker} ${dim(worker.dir)}`);
|
|
99
|
+
|
|
100
|
+
const environments = filter
|
|
101
|
+
? worker.environments.filter((environment) => environment.name === filter)
|
|
102
|
+
: worker.environments;
|
|
103
|
+
if (environments.length === 0) {
|
|
104
|
+
lines.push(` ${dim(`No environment named ${filter}.`)}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const environment of environments) {
|
|
109
|
+
lines.push(` ${environment.name} ${dim(environment.baseUrl ?? "no url")}`);
|
|
110
|
+
if (environment.scriptName) {
|
|
111
|
+
lines.push(
|
|
112
|
+
` worker ${renderValue(environment.scriptName, true, environment.workerDashboardUrl, environment.local)}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (const resource of environment.resources) {
|
|
116
|
+
lines.push(
|
|
117
|
+
` ${resource.binding} (${KIND_LABEL[resource.kind]}) ${renderValue(resource.id, resource.provisioned, resource.dashboardUrl, environment.local)}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (environment.local && environment.resources.some((resource) => !resource.provisioned)) {
|
|
121
|
+
lines.push(` ${dim(UNGATED)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default defineCommand({
|
|
129
|
+
meta: {
|
|
130
|
+
name: "env",
|
|
131
|
+
description: "Inventory every worker's environments: bindings, ids, provisioned state, dashboard links",
|
|
132
|
+
},
|
|
133
|
+
args: {
|
|
134
|
+
name: { type: "positional", required: false, description: "Show only this environment (e.g. staging)" },
|
|
135
|
+
worker: { type: "string", description: "Show only this worker (default: every worker under apps/)" },
|
|
136
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
137
|
+
},
|
|
138
|
+
run: ({ args }) =>
|
|
139
|
+
withErrorReporting(args.json, async () => {
|
|
140
|
+
const projectDir = process.cwd();
|
|
141
|
+
// The account first, from the project's own config: the inventory *prints* an account id and
|
|
142
|
+
// builds every dashboard link out of it, so resolving one this project did not claim labels its
|
|
143
|
+
// bindings with another company's account and links there (#206).
|
|
144
|
+
const inventory = await buildEnvInventory({
|
|
145
|
+
projectDir,
|
|
146
|
+
account: await projectCloudflareAccount(projectDir),
|
|
147
|
+
...(args.worker ? { worker: args.worker } : {}),
|
|
148
|
+
});
|
|
149
|
+
if (args.json) {
|
|
150
|
+
process.stdout.write(`${formatJsonLine({ command: "env", ...inventory })}\n`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write(renderEnvInventory(inventory, args.name));
|
|
154
|
+
}),
|
|
155
|
+
});
|