void 0.20.1 → 0.20.3
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/README.md +5 -1
- package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
- package/dist/{auth-cmd-CAH62yDU.mjs → auth-cmd-CzwquNiP.mjs} +5 -4
- package/dist/auth-link-ElDTgF7j.mjs +28 -0
- package/dist/{build-cmd-CJvZvPQO.mjs → build-cmd-BpJe6boP.mjs} +3 -3
- package/dist/{cache-BlNeQjuP.mjs → cache-D98YTqeE.mjs} +3 -3
- package/dist/{cancel-deploy-CmlAZ9P6.mjs → cancel-deploy-CPbEQLMG.mjs} +3 -3
- package/dist/cf-access-DRsQRe6k.mjs +75 -0
- package/dist/cli/cli.mjs +309 -1958
- package/dist/cli/env-schema-probe.mjs +11 -2
- package/dist/client-BQBrZoCX.mjs +989 -0
- package/dist/{cloudflare-auth-B1QtTO1b.mjs → cloudflare-auth-6M5llVPC.mjs} +2 -2
- package/dist/{cloudflare-cmd-B6_OZx2V.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
- package/dist/{cloudflare-connect-j5D4hhrG.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
- package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
- package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
- package/dist/{connect-C04Wdy_h.mjs → connect-WCQZ_u3m.mjs} +6 -6
- package/dist/{create-project-ChGZ1DFd.mjs → create-project-D0oXA090.mjs} +7 -7
- package/dist/{db-D2d_mUsB.mjs → db-DJ-9qs3S.mjs} +47 -30
- package/dist/{delete-D8GigDk8.mjs → delete-BZ4-WaGm.mjs} +3 -3
- package/dist/{deploy-iXZ3F0N6.mjs → deploy-BAhhcg5q.mjs} +135 -117
- package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
- package/dist/{domain-B1VmoSr0.mjs → domain-BxAyhxXN.mjs} +4 -4
- package/dist/email-Bj7Cvdwp.mjs +795 -0
- package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
- package/dist/{env-BcQzYgoG.mjs → env-DP_EErve.mjs} +5 -5
- package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
- package/dist/{gen-DI2YwdBM.mjs → gen-B_wPnVTK.mjs} +2 -2
- package/dist/{github-cmd-xItS5Zwf.mjs → github-cmd-C-z_xRrQ.mjs} +15 -21
- package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
- package/dist/help-CwOX-zmI.mjs +2216 -0
- package/dist/{inbound-afAcWeQ9.d.mts → inbound-CH5Mksyy.d.mts} +34 -48
- package/dist/{inbound-2d0zi2yS.mjs → inbound-aVHEUhKo.mjs} +130 -100
- package/dist/index.mjs +54 -17
- package/dist/{init-BD-9THgn.mjs → init-BGktCXgA.mjs} +11 -11
- package/dist/{link-RMdgjF1v.mjs → link-D2kbqhWb.mjs} +4 -4
- package/dist/{list-3F52R_yO.mjs → list-CvkK_G7k.mjs} +4 -4
- package/dist/{login-pV69H-ZO.mjs → login-WIjNc77c.mjs} +28 -10
- package/dist/{logs-DFHHD6wE.mjs → logs-dLUFCapG.mjs} +4 -4
- package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
- package/dist/{node-Dk3H2jmU.mjs → node-Ez5KW5rn.mjs} +2 -2
- package/dist/operator-auth-B3e08unv.mjs +52 -0
- package/dist/operator-client-LUZnlnYk.mjs +82 -0
- package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CKJ7xIRs.mjs} +35 -55
- package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
- package/dist/pages/index.mjs +2 -2
- package/dist/platform-auth-config-CdVWRRJr.mjs +368 -0
- package/dist/platform-auth-protection-Drl0qhrn.mjs +219 -0
- package/dist/platform-auth-recovery-CmKEWpDo.mjs +310 -0
- package/dist/{platform-cmd-DxJ2FRwR.mjs → platform-cmd-5q_k56xS.mjs} +16 -6
- package/dist/{platform-domain-ChvbJkdy.mjs → platform-domain-4GiDlcqx.mjs} +4 -4
- package/dist/{platform-lifecycle-DN4MzJF_.mjs → platform-lifecycle-R9xAxvtG.mjs} +1716 -204
- package/dist/{platform-management-Db2PXw0B.mjs → platform-management-BfWsXHEW.mjs} +35 -7
- package/dist/{platform-recovery-C_YO-tIs.mjs → platform-recovery-CbK-I1FB.mjs} +6 -5
- package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
- package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
- package/dist/prerender-render.d.mts +11 -0
- package/dist/prerender-render.mjs +111 -0
- package/dist/{project-cmd-Mo0V9yKS.mjs → project-cmd-CTdmnzvc.mjs} +32 -14
- package/dist/project-team-CGxsQe3_.mjs +132 -0
- package/dist/project-token-Cirx7uwZ.mjs +75 -0
- package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
- package/dist/{requests-BcKOVpRg.mjs → requests-4Nq59hOr.mjs} +3 -3
- package/dist/{rollback-Bx85-0xh.mjs → rollback-Dr7u0Ljx.mjs} +4 -4
- package/dist/runtime/ai.mjs +3 -2
- package/dist/runtime/email/testing.d.mts +1 -1
- package/dist/runtime/email/testing.mjs +3 -3
- package/dist/runtime/email-protocol.d.mts +15 -0
- package/dist/runtime/email-protocol.mjs +70 -0
- package/dist/runtime/email.d.mts +2 -2
- package/dist/runtime/email.mjs +189 -96
- package/dist/runtime/remote/index.mjs +54 -11
- package/dist/runtime/sandbox.d.mts +4 -56
- package/dist/runtime/sandbox.mjs +81 -220
- package/dist/{secret-ByhJ9AMl.mjs → secret-AcPi-FoA.mjs} +5 -5
- package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
- package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
- package/package.json +12 -7
- package/skills/void/SKILL.md +35 -4
- package/skills/void/docs/guide/deployment.md +16 -14
- package/skills/void/docs/guide/email.md +114 -120
- package/skills/void/docs/guide/platform/administration/access.md +163 -0
- package/skills/void/docs/guide/platform/administration/email.md +121 -0
- package/skills/void/docs/guide/platform/administration/operations.md +97 -0
- package/skills/void/docs/guide/platform/administration/projects.md +54 -0
- package/skills/void/docs/guide/platform/development/local.md +119 -0
- package/skills/void/docs/guide/platform/development/runtime.md +124 -0
- package/skills/void/docs/guide/platform/development/schema-ci.md +95 -0
- package/skills/void/docs/guide/platform/installation/ci.md +55 -0
- package/skills/void/docs/guide/platform/installation/credentials.md +80 -0
- package/skills/void/docs/guide/platform/installation/domains.md +68 -0
- package/skills/void/docs/guide/platform/installation/first-deployment.md +82 -0
- package/skills/void/docs/guide/platform/installation/maintenance.md +137 -0
- package/skills/void/docs/guide/platform/installation/prerequisites.md +86 -0
- package/skills/void/docs/guide/platform/installation/setup.md +169 -0
- package/skills/void/docs/guide/platform/installation/uninstall.md +54 -0
- package/skills/void/docs/guide/platform-administration.md +6 -202
- package/skills/void/docs/guide/platform-development.md +5 -254
- package/skills/void/docs/guide/project-collaboration.md +94 -0
- package/skills/void/docs/guide/sandboxes.md +9 -24
- package/skills/void/docs/guide/self-hosted-platform.md +13 -542
- package/skills/void/docs/reference/api.md +34 -34
- package/skills/void/docs/reference/cli.md +276 -31
- package/skills/void/docs/reference/config.md +1 -1
- package/skills/void/docs/reference/resource-inference.md +10 -10
- package/dist/cf-access-AJ1ehiFR.mjs +0 -42
- package/dist/cf-access-DsSsZUPr.mjs +0 -67
- package/dist/client-Clirrol3.mjs +0 -705
- package/dist/email-uKyQYUVY.mjs +0 -1016
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
import { o as join } from "./dist-BrsS7cai.mjs";
|
|
2
|
+
import { D as log, I as isCancel, c as import_picocolors, i as createSpinner, j as password, k as note, w as confirm } from "./output-B0cfNSx5.mjs";
|
|
3
|
+
import { C as renderAddressMap, S as preflightEmail, a as isWranglerNonInteractiveOrCI, b as createNodeEmailDns, i as discoverRootWranglerConfig, o as loadProvisionModule, r as deriveWorkerName, s as resolveDeployTransport, t as createDeployWranglerRunner, u as runWranglerAuthToken, w as renderEmailChecklist, x as findEmailConfigConflicts, y as applyEmailSetup } from "./deploy-BAhhcg5q.mjs";
|
|
4
|
+
import { n as readProjectPaths } from "./project-paths-SK8nMHPp.mjs";
|
|
5
|
+
import { S as readProjectConfig, f as openBrowser, l as getToken } from "./auth-DPl6kck4.mjs";
|
|
6
|
+
import { k as writeProvisionedWranglerConfig } from "./wrangler--imS8n0d.mjs";
|
|
7
|
+
import { f as readConfig } from "./config-uNGuFsI2.mjs";
|
|
8
|
+
import { a as scanEmailHandlersSync } from "./email-Ce6SQq-i.mjs";
|
|
9
|
+
import { n as PlatformClient, t as PlatformApiError } from "./client-BQBrZoCX.mjs";
|
|
10
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { parseRootWranglerConfig, readWranglerTopLevelString } from "@void/deploy-cloudflare";
|
|
14
|
+
//#region src/cli/email.ts
|
|
15
|
+
async function runEmailCommand(root, args) {
|
|
16
|
+
if (args.platform === "cloudflare") return runEmailCloudflareCommand(root, args);
|
|
17
|
+
if (args.subcommand === "setup") {
|
|
18
|
+
log.error("email: `void email setup` sets email up on your OWN Cloudflare account — run it with `--platform cloudflare`. The Void platform needs no setup: deploy, and `void email usage` shows your address.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
if (args.subcommand === "status") {
|
|
22
|
+
log.error("email: `void email status` is available with `--platform cloudflare` today. On the Void platform use `void email usage` and `void email destinations`.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const token = getToken(root);
|
|
26
|
+
if (!token) {
|
|
27
|
+
log.error("Not logged in. Run `void auth login` first.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const client = new PlatformClient(token, { root });
|
|
31
|
+
const requestedSlug = args.projectSlug ?? process.env.VOID_PROJECT?.trim();
|
|
32
|
+
let projectId;
|
|
33
|
+
if (requestedSlug) {
|
|
34
|
+
const project = (await client.listProjects()).find((p) => p.slug === requestedSlug);
|
|
35
|
+
if (!project) {
|
|
36
|
+
log.error(`project: No project found with slug '${requestedSlug}'.`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
projectId = project.id;
|
|
40
|
+
} else {
|
|
41
|
+
const config = readProjectConfig(root);
|
|
42
|
+
if (!config) {
|
|
43
|
+
log.error("No linked project. Run `void project link` or pass --project <name>.");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
projectId = config.projectId;
|
|
47
|
+
}
|
|
48
|
+
switch (args.subcommand) {
|
|
49
|
+
case "usage": return emailUsage(client, projectId);
|
|
50
|
+
case "logs": return emailLogs(client, projectId, args.limit);
|
|
51
|
+
case "destinations": return emailDestinations(client, projectId);
|
|
52
|
+
case "allow": return emailAllow(client, projectId, args.address);
|
|
53
|
+
case "disallow": return emailDisallow(client, projectId, args.address);
|
|
54
|
+
case "domain": return runEmailDomainCommand(client, projectId, args);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function emailUsage(client, projectId) {
|
|
58
|
+
const usage = await client.getEmailUsage(projectId);
|
|
59
|
+
const status = usage.suspended ? import_picocolors.default.red("SUSPENDED") : import_picocolors.default.green("active");
|
|
60
|
+
const lines = [
|
|
61
|
+
`Month: ${usage.month}`,
|
|
62
|
+
`Status: ${status}`,
|
|
63
|
+
`Recipient attempts: ${usage.attempts} / ${usage.limit} (${usage.remaining} remaining)`,
|
|
64
|
+
`Inbound receipts: ${usage.received}`
|
|
65
|
+
];
|
|
66
|
+
note(lines.join("\n"), "Email Usage");
|
|
67
|
+
}
|
|
68
|
+
async function emailDestinations(client, projectId) {
|
|
69
|
+
const rows = await client.listEmailDestinations(projectId);
|
|
70
|
+
if (rows.length === 0) {
|
|
71
|
+
log.info("No verified destinations. Run `void email allow <address>` to add one — Cloudflare will email the recipient with a verification link.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const lines = rows.map((d) => {
|
|
75
|
+
const status = d.status === "verified" ? import_picocolors.default.green("verified") : d.status === "pending" ? import_picocolors.default.yellow("pending") : import_picocolors.default.red("failed");
|
|
76
|
+
return ` ${d.address.padEnd(40)} ${status}`;
|
|
77
|
+
});
|
|
78
|
+
note(lines.join("\n"), "Email Destinations");
|
|
79
|
+
}
|
|
80
|
+
async function emailAllow(client, projectId, address) {
|
|
81
|
+
const dest = await client.addEmailDestination(projectId, address);
|
|
82
|
+
const sent = dest.verificationSent !== false && (dest.created || dest.platformVerificationPending);
|
|
83
|
+
if (dest.status === "verified") log.success(`${dest.address} is already verified.`);
|
|
84
|
+
else if (sent) {
|
|
85
|
+
log.success(`Verification email sent to ${dest.address}.`);
|
|
86
|
+
log.info("They click the link in their inbox — no Void or Cloudflare account needed. `void email destinations` to poll status.");
|
|
87
|
+
} else if (dest.created) log.warn(`${dest.address} is on this project's list now, but Cloudflare already had the address pending (added by another project, or by hand), so no new verification email was sent — the link it mailed when the address was first added is the one to click. ${freshLinkHint(dest.address)}`);
|
|
88
|
+
else log.info(`${dest.address} is still pending — Cloudflare emailed them a verification link when the address was first added, and a repeat add sends nothing new. ${freshLinkHint(dest.address)}`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Cloudflare has no API to resend its verification mail; the only fresh link
|
|
92
|
+
* is a fresh registration, and the platform deletes the Cloudflare-side
|
|
93
|
+
* address only once no project still lists it.
|
|
94
|
+
*/
|
|
95
|
+
function freshLinkHint(address) {
|
|
96
|
+
return `For a fresh link, remove the address from every project that lists it (\`void email disallow ${address}\`), then \`void email allow ${address}\` — Cloudflare mails a new link only when it registers the address anew.`;
|
|
97
|
+
}
|
|
98
|
+
async function emailDisallow(client, projectId, address) {
|
|
99
|
+
const dest = (await client.listEmailDestinations(projectId)).find((d) => d.address.toLowerCase() === address.toLowerCase());
|
|
100
|
+
if (!dest) {
|
|
101
|
+
log.error(`destination: '${address}' is not in the project's allowlist.`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
await client.removeEmailDestination(projectId, dest.id);
|
|
105
|
+
log.success(`Removed ${dest.address}. New sends to it are refused immediately; already admitted attempts may finish.`);
|
|
106
|
+
}
|
|
107
|
+
async function emailLogs(client, projectId, limit) {
|
|
108
|
+
const result = await client.getEmailLogs(projectId, limit);
|
|
109
|
+
if (result.logs.length === 0) {
|
|
110
|
+
log.info("No email activity recorded.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const lines = result.logs.map((entry) => {
|
|
114
|
+
return `${new Date(entry.timestamp).toISOString()} ${entry.direction === "sent" ? "→" : "←"} ${entry.operationId} ${entry.recipient ?? "—"} ${entry.status}${entry.errorCode ? ` (${entry.errorCode})` : ""}`;
|
|
115
|
+
});
|
|
116
|
+
note(lines.join("\n"), "Email Logs");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The dashboard template link's permission groups (contract 8). Every key
|
|
120
|
+
* rides every link — there is no apex-only key, because `Zone Settings Edit`
|
|
121
|
+
* gates the routing POST on both paths; `zone: read` is what the zone lookup
|
|
122
|
+
* needs. URL-encoded by `URL.searchParams`.
|
|
123
|
+
*/
|
|
124
|
+
const CF_TOKEN_TEMPLATE_PERMISSION_GROUPS = [
|
|
125
|
+
{
|
|
126
|
+
key: "zone",
|
|
127
|
+
type: "read"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: "email_routing_rule",
|
|
131
|
+
type: "edit"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "email_sending",
|
|
135
|
+
type: "edit"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "workers_scripts",
|
|
139
|
+
type: "edit"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "zone_settings",
|
|
143
|
+
type: "edit"
|
|
144
|
+
}
|
|
145
|
+
];
|
|
146
|
+
/** The three-click template link — the scoped token path (0078, "Creating the token in three clicks"). */
|
|
147
|
+
function buildCfApiTokenTemplateUrl(domain) {
|
|
148
|
+
const url = new URL("https://dash.cloudflare.com/profile/api-tokens");
|
|
149
|
+
url.searchParams.set("permissionGroupKeys", JSON.stringify(CF_TOKEN_TEMPLATE_PERMISSION_GROUPS));
|
|
150
|
+
url.searchParams.set("accountId", "*");
|
|
151
|
+
url.searchParams.set("zoneId", "all");
|
|
152
|
+
url.searchParams.set("name", `Void email — ${domain}`);
|
|
153
|
+
return url.href;
|
|
154
|
+
}
|
|
155
|
+
const CFUT_TOKEN_PATTERN = /^cfut_[A-Za-z0-9_-]{44,64}$/;
|
|
156
|
+
const LEGACY_CF_TOKEN_PATTERN = /^[A-Za-z0-9_-]{40}$/;
|
|
157
|
+
const REFUSED_TOKEN_PREFIXES = [
|
|
158
|
+
"ghp_",
|
|
159
|
+
"github_pat_",
|
|
160
|
+
"sk-",
|
|
161
|
+
"xox",
|
|
162
|
+
"cfk_"
|
|
163
|
+
];
|
|
164
|
+
/** Shape-only classification of a clipboard or pasted value. Never logs the value. */
|
|
165
|
+
function classifyCfApiTokenShape(value) {
|
|
166
|
+
const token = value.trim();
|
|
167
|
+
if (REFUSED_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) return "refused";
|
|
168
|
+
if (CFUT_TOKEN_PATTERN.test(token)) return "cfut";
|
|
169
|
+
if (LEGACY_CF_TOKEN_PATTERN.test(token)) return "legacy";
|
|
170
|
+
return "unknown";
|
|
171
|
+
}
|
|
172
|
+
function readClipboard() {
|
|
173
|
+
const platform = process.platform;
|
|
174
|
+
const command = platform === "darwin" ? "pbpaste" : platform === "win32" ? "powershell -NoProfile -Command Get-Clipboard" : "xclip -o -selection clipboard";
|
|
175
|
+
try {
|
|
176
|
+
return execSync(command, {
|
|
177
|
+
encoding: "utf8",
|
|
178
|
+
stdio: [
|
|
179
|
+
"ignore",
|
|
180
|
+
"pipe",
|
|
181
|
+
"ignore"
|
|
182
|
+
]
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
if (platform !== "darwin" && platform !== "win32") try {
|
|
186
|
+
return execSync("wl-paste", {
|
|
187
|
+
encoding: "utf8",
|
|
188
|
+
stdio: [
|
|
189
|
+
"ignore",
|
|
190
|
+
"pipe",
|
|
191
|
+
"ignore"
|
|
192
|
+
]
|
|
193
|
+
});
|
|
194
|
+
} catch {}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** `pbcopy </dev/null` and the platform equivalents — best-effort, never throws. */
|
|
199
|
+
function clearClipboard() {
|
|
200
|
+
const platform = process.platform;
|
|
201
|
+
try {
|
|
202
|
+
if (platform === "darwin") execSync("pbcopy", { input: "" });
|
|
203
|
+
else if (platform === "win32") execSync("clip", { input: "" });
|
|
204
|
+
else try {
|
|
205
|
+
execSync("xclip -selection clipboard", { input: "" });
|
|
206
|
+
} catch {
|
|
207
|
+
execSync("wl-copy", { input: "" });
|
|
208
|
+
}
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The CLI-side active check from 0078's clipboard sequence. 'unknown' means
|
|
213
|
+
* the check could not run (no network, a CF error) — the caller proceeds and
|
|
214
|
+
* lets the platform verify authoritatively, never treating a network blip as
|
|
215
|
+
* a bad token. The token itself is never logged.
|
|
216
|
+
*/
|
|
217
|
+
async function verifyCfApiToken(token) {
|
|
218
|
+
let res;
|
|
219
|
+
try {
|
|
220
|
+
res = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", { headers: { authorization: `Bearer ${token}` } });
|
|
221
|
+
} catch {
|
|
222
|
+
return "unknown";
|
|
223
|
+
}
|
|
224
|
+
if (res.status === 401 || res.status === 403) return "inactive";
|
|
225
|
+
if (!res.ok) return "unknown";
|
|
226
|
+
try {
|
|
227
|
+
return (await res.json()).result?.status === "active" ? "active" : "inactive";
|
|
228
|
+
} catch {
|
|
229
|
+
return "unknown";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Mirror of the api's normalization (`normalizeEmailDomain`). */
|
|
233
|
+
function normalizeEmailDomainInput(raw) {
|
|
234
|
+
return raw.trim().toLowerCase().replace(/\.$/, "");
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Same DNS-hostname shape the api enforces — checked here so junk fails
|
|
238
|
+
* before a grant is ever requested, not after. Real validation stays the
|
|
239
|
+
* platform's zone lookup.
|
|
240
|
+
*/
|
|
241
|
+
function isValidEmailDomainInput(domain) {
|
|
242
|
+
return /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/.test(domain);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Where `--subdomain` lands: a bare label nests under the positional domain
|
|
246
|
+
* (`mx` → `mx.acme.com`), a full host is taken as-is but must stay under it.
|
|
247
|
+
* Throws on anything else — the user named a domain this add cannot register.
|
|
248
|
+
*/
|
|
249
|
+
function resolveEmailDomainTarget(domain, subdomain) {
|
|
250
|
+
if (subdomain === void 0) return {
|
|
251
|
+
emailDomain: domain,
|
|
252
|
+
forced: false
|
|
253
|
+
};
|
|
254
|
+
const candidate = subdomain.trim().toLowerCase().replace(/\.$/, "");
|
|
255
|
+
const resolved = candidate.includes(".") ? candidate : `${candidate}.${domain}`;
|
|
256
|
+
if (!isValidEmailDomainInput(resolved)) throw new Error(`email: invalid --subdomain value '${subdomain}'.`);
|
|
257
|
+
if (resolved === domain || !resolved.endsWith(`.${domain}`)) throw new Error(`email: --subdomain must name a subdomain of ${domain} (got '${subdomain}').`);
|
|
258
|
+
return {
|
|
259
|
+
emailDomain: resolved,
|
|
260
|
+
forced: true
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Local public-DNS MX read. [] = no records, null = could not read. The
|
|
265
|
+
* local answer only steers the default and the render — the platform
|
|
266
|
+
* re-checks over public DNS and its answer is authoritative, so a
|
|
267
|
+
* split-horizon resolver never blocks a valid setup here.
|
|
268
|
+
*/
|
|
269
|
+
async function lookupMxForRender(dns, name) {
|
|
270
|
+
try {
|
|
271
|
+
return (await dns.resolveMx(name)).slice().sort((a, b) => a.priority - b.priority).map((r) => r.exchange.replace(/\.$/, "").toLowerCase());
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const code = err?.code;
|
|
274
|
+
return code === "ENODATA" || code === "ENOTFOUND" ? [] : null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The "Enter switches paths" race from 0078's flow. The default reads one
|
|
279
|
+
* cooked-mode line from stdin; `stop` removes the listener so a losing race
|
|
280
|
+
* cannot eat the first keystroke of a later prompt.
|
|
281
|
+
*/
|
|
282
|
+
function createEnterRace(waitForEnter) {
|
|
283
|
+
if (waitForEnter) return {
|
|
284
|
+
promise: waitForEnter().then(() => "enter"),
|
|
285
|
+
stop: () => {}
|
|
286
|
+
};
|
|
287
|
+
const stdin = process.stdin;
|
|
288
|
+
let onData;
|
|
289
|
+
return {
|
|
290
|
+
promise: new Promise((resolve) => {
|
|
291
|
+
if (!stdin.isTTY) return;
|
|
292
|
+
onData = () => resolve("enter");
|
|
293
|
+
stdin.on("data", onData);
|
|
294
|
+
}),
|
|
295
|
+
stop: () => {
|
|
296
|
+
if (onData) stdin.removeListener("data", onData);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function defaultPasteToken() {
|
|
301
|
+
const result = await password({
|
|
302
|
+
message: "Paste the Cloudflare API token:",
|
|
303
|
+
mask: "*"
|
|
304
|
+
});
|
|
305
|
+
if (isCancel(result) || typeof result === "symbol") return null;
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Lost-answer reconcile bounds for `domain add`: attempt-count bounded (not
|
|
310
|
+
* wall clock) so the worst case is finite and tests are deterministic. The
|
|
311
|
+
* API's pre-insert phase is DB-only but takes tens of ms to seconds of
|
|
312
|
+
* Hyperdrive RTTs, and a lost answer does not stop the API worker — a single
|
|
313
|
+
* immediate status read can 404 while the POST is still committing.
|
|
314
|
+
*/
|
|
315
|
+
const ADD_RECONCILE_ATTEMPTS = 15;
|
|
316
|
+
const ADD_RECONCILE_INTERVAL_MS = 1e3;
|
|
317
|
+
async function emailDomainAdd(client, projectId, rawDomain, subdomain, deps = {}) {
|
|
318
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
319
|
+
const fail = (message) => {
|
|
320
|
+
log.error(message);
|
|
321
|
+
return exit(1);
|
|
322
|
+
};
|
|
323
|
+
const domain = normalizeEmailDomainInput(rawDomain);
|
|
324
|
+
if (!isValidEmailDomainInput(domain)) return fail(`email: invalid domain '${rawDomain}'.`);
|
|
325
|
+
let target;
|
|
326
|
+
try {
|
|
327
|
+
target = resolveEmailDomainTarget(domain, subdomain);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
330
|
+
}
|
|
331
|
+
const dns = deps.dns ?? await createNodeEmailDns();
|
|
332
|
+
const decideTarget = async () => {
|
|
333
|
+
if (target.forced) {
|
|
334
|
+
const mx = await lookupMxForRender(dns, target.emailDomain);
|
|
335
|
+
if (mx !== null && mx.length > 0) log.warn(`MX ${target.emailDomain} already carries MX (${mx.join(", ")}) — the platform re-checks over public DNS and may refuse it.`);
|
|
336
|
+
} else {
|
|
337
|
+
const mx = await lookupMxForRender(dns, domain);
|
|
338
|
+
if (mx === null) log.info(`MX could not read ${domain}'s MX from here — the platform decides over public DNS.`);
|
|
339
|
+
else if (mx.length > 0) {
|
|
340
|
+
target = {
|
|
341
|
+
emailDomain: `mail.${domain}`,
|
|
342
|
+
forced: false
|
|
343
|
+
};
|
|
344
|
+
log.info(`MX ${domain} carries MX for ${mx[0]} → apex is off limits\n using ${target.emailDomain} [change with --subdomain]`);
|
|
345
|
+
const subMx = await lookupMxForRender(dns, target.emailDomain);
|
|
346
|
+
if (subMx !== null && subMx.length > 0) log.warn(`MX ${target.emailDomain} also carries MX (${subMx.join(", ")}) — the platform may refuse it; pick another with --subdomain.`);
|
|
347
|
+
} else log.info(`MX ${domain} has no MX records → using the apex`);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
let renewal;
|
|
351
|
+
const settings = await client.getEmailSettings(projectId);
|
|
352
|
+
if (settings.domains.management === "admin") {
|
|
353
|
+
const rows = await client.listEmailDomains(projectId);
|
|
354
|
+
const existing = [target.emailDomain, ...target.forced ? [] : [`mail.${domain}`]].map((candidate) => rows.find((row) => row.domain === candidate && row.managed_by !== "admin")).find((row) => row !== void 0);
|
|
355
|
+
if (!existing) {
|
|
356
|
+
await decideTarget();
|
|
357
|
+
const project = settings.domains.projectSlug ?? projectId;
|
|
358
|
+
log.info([
|
|
359
|
+
"Email domains on this platform are managed by its administrator.",
|
|
360
|
+
"Ask them to run:",
|
|
361
|
+
"",
|
|
362
|
+
` void platform email domain-add ${target.emailDomain} --project ${project}`,
|
|
363
|
+
"",
|
|
364
|
+
"`void email domain list` and `void email domain status` keep working here."
|
|
365
|
+
].join("\n"));
|
|
366
|
+
return exit(0);
|
|
367
|
+
}
|
|
368
|
+
renewal = existing.domain;
|
|
369
|
+
target = {
|
|
370
|
+
emailDomain: existing.domain,
|
|
371
|
+
forced: true
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (renewal !== void 0) log.info(`Updating ${renewal} with a scoped credential; the platform will reconcile setup.`);
|
|
375
|
+
else await decideTarget();
|
|
376
|
+
if (!(deps.isInteractive ?? !isWranglerNonInteractiveOrCI())) return fail("email: `void email domain add` needs an interactive terminal to create or paste a scoped Cloudflare API token. Run it locally.");
|
|
377
|
+
let credential;
|
|
378
|
+
{
|
|
379
|
+
log.info(`Create a Cloudflare API token restricted to the account and DNS zone containing ${target.emailDomain}. In the resource selectors, replace All accounts and All zones with those exact resources. Workers Scripts Write still applies to every Worker in the selected account.`);
|
|
380
|
+
const url = buildCfApiTokenTemplateUrl(target.emailDomain);
|
|
381
|
+
const readClip = deps.readClipboard ?? readClipboard;
|
|
382
|
+
const clearClip = deps.clearClipboard ?? clearClipboard;
|
|
383
|
+
const verify = deps.verifyToken ?? verifyCfApiToken;
|
|
384
|
+
const openUrl = deps.openUrl ?? ((u) => {
|
|
385
|
+
import("node:child_process").then(({ execFile }) => openBrowser(u, execFile));
|
|
386
|
+
});
|
|
387
|
+
const snapshot = readClip();
|
|
388
|
+
if (snapshot === null) log.info("No clipboard tool found here — paste the token instead.");
|
|
389
|
+
openUrl(url);
|
|
390
|
+
let outcome;
|
|
391
|
+
let watchSpinner;
|
|
392
|
+
if (snapshot === null) outcome = { type: "timeout" };
|
|
393
|
+
else {
|
|
394
|
+
watchSpinner = createSpinner();
|
|
395
|
+
watchSpinner.start([
|
|
396
|
+
"Create the token in three clicks: Continue to summary → Create Token → Copy.",
|
|
397
|
+
"Watching the clipboard for it — nothing is read until you copy, and it is cleared afterwards.",
|
|
398
|
+
"(Enter pastes it yourself instead.)",
|
|
399
|
+
`If the browser didn't open, visit:\n${import_picocolors.default.cyan(url)}`
|
|
400
|
+
].join("\n"));
|
|
401
|
+
const spinner = watchSpinner;
|
|
402
|
+
const deadline = Date.now() + (deps.watchTimeoutMs ?? 6e5);
|
|
403
|
+
const interval = deps.watchIntervalMs ?? 1e3;
|
|
404
|
+
const snapshotTrimmed = snapshot.trim();
|
|
405
|
+
const enterDuringWatch = createEnterRace(deps.waitForEnter);
|
|
406
|
+
let watchSettled = false;
|
|
407
|
+
let watchTimer;
|
|
408
|
+
outcome = await Promise.race([(async () => {
|
|
409
|
+
while (!watchSettled && Date.now() < deadline) {
|
|
410
|
+
await new Promise((resolve) => {
|
|
411
|
+
watchTimer = setTimeout(resolve, interval);
|
|
412
|
+
});
|
|
413
|
+
if (watchSettled) break;
|
|
414
|
+
const value = readClip()?.trim();
|
|
415
|
+
if (!value || value === snapshotTrimmed) continue;
|
|
416
|
+
const shape = classifyCfApiTokenShape(value);
|
|
417
|
+
if (shape === "refused" || shape === "unknown") continue;
|
|
418
|
+
const verdict = await verify(value);
|
|
419
|
+
if (verdict === "inactive") {
|
|
420
|
+
spinner.message("That is not an active Cloudflare token — still waiting.");
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (verdict === "unknown") log.info("Could not reach Cloudflare to check the token — the platform will verify it.");
|
|
424
|
+
return {
|
|
425
|
+
type: "token",
|
|
426
|
+
token: value
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return { type: "timeout" };
|
|
430
|
+
})(), enterDuringWatch.promise.then(() => ({ type: "enter" }))]);
|
|
431
|
+
watchSettled = true;
|
|
432
|
+
enterDuringWatch.stop();
|
|
433
|
+
watchTimer?.unref();
|
|
434
|
+
}
|
|
435
|
+
if (outcome.type === "token") {
|
|
436
|
+
clearClip();
|
|
437
|
+
watchSpinner?.stop("Token received — the clipboard copy was cleared.");
|
|
438
|
+
log.info("A clipboard manager may keep its own history — clear it there if you run one.");
|
|
439
|
+
credential = {
|
|
440
|
+
kind: "api_token",
|
|
441
|
+
apiToken: outcome.token
|
|
442
|
+
};
|
|
443
|
+
} else {
|
|
444
|
+
watchSpinner?.stop(outcome.type === "enter" ? "Pasting instead." : "No token arrived — paste it instead.");
|
|
445
|
+
const pasted = await (deps.pasteToken ?? defaultPasteToken)();
|
|
446
|
+
if (pasted === null) {
|
|
447
|
+
log.info("Cancelled — nothing was stored anywhere.");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const token = pasted.trim();
|
|
451
|
+
const shape = classifyCfApiTokenShape(token);
|
|
452
|
+
if (shape === "refused") return fail("email: that looks like a different kind of token (a GitHub, OpenAI, Slack, or Cloudflare global key) — the link above creates a scoped Cloudflare API token.");
|
|
453
|
+
if (shape === "unknown") return fail("email: that is not shaped like a Cloudflare API token (`cfut_…` or the legacy 40-character form). Create one from the link above.");
|
|
454
|
+
const verdict = await verify(token);
|
|
455
|
+
if (verdict === "inactive") return fail("email: that is not an active Cloudflare token.");
|
|
456
|
+
if (verdict === "unknown") log.info("Could not reach Cloudflare to check the token — the platform will verify it.");
|
|
457
|
+
credential = {
|
|
458
|
+
kind: "api_token",
|
|
459
|
+
apiToken: token
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!await (deps.confirmScope ?? (async (domain) => {
|
|
464
|
+
const answer = await confirm({
|
|
465
|
+
message: `Is this token restricted to the Cloudflare account and DNS zone containing ${domain}?`,
|
|
466
|
+
initialValue: false
|
|
467
|
+
});
|
|
468
|
+
return !isCancel(answer) && answer === true;
|
|
469
|
+
}))(target.emailDomain)) return fail("email: restrict the token to the selected account and zone before adding the domain. Nothing was submitted to the platform.");
|
|
470
|
+
const attemptId = crypto.randomUUID();
|
|
471
|
+
log.info(`Setup operation: ${attemptId}`);
|
|
472
|
+
let row;
|
|
473
|
+
try {
|
|
474
|
+
row = await client.addEmailDomain(projectId, {
|
|
475
|
+
domain: target.emailDomain,
|
|
476
|
+
apiToken: credential.apiToken,
|
|
477
|
+
attemptId
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error instanceof PlatformApiError && [
|
|
481
|
+
400,
|
|
482
|
+
403,
|
|
483
|
+
404,
|
|
484
|
+
409
|
|
485
|
+
].includes(error.status)) throw error;
|
|
486
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
487
|
+
for (let attempt = 0; attempt < ADD_RECONCILE_ATTEMPTS; attempt++) {
|
|
488
|
+
if (attempt > 0) await sleep(ADD_RECONCILE_INTERVAL_MS);
|
|
489
|
+
let operation;
|
|
490
|
+
try {
|
|
491
|
+
operation = await client.getEmailOperation(projectId, attemptId);
|
|
492
|
+
} catch {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
note([
|
|
496
|
+
`Operation: ${operation.operationId}`,
|
|
497
|
+
`Status: ${operation.status}`,
|
|
498
|
+
`Inbound: ${operation.readiness.inbound.status}`,
|
|
499
|
+
`Outbound: ${operation.readiness.outbound.status}`,
|
|
500
|
+
`Credential: ${operation.readiness.management.status}`
|
|
501
|
+
].join("\n"), "Email Setup");
|
|
502
|
+
if (operation.error) log.error(operation.error);
|
|
503
|
+
log.info(`Run \`void email domain status ${target.emailDomain}\` to inspect setup.`);
|
|
504
|
+
if (operation.status === "completed") return;
|
|
505
|
+
return fail(`email: setup is ${operation.status}; operation ${operation.operationId} retains its confirmed progress on the platform.`);
|
|
506
|
+
}
|
|
507
|
+
return fail(`email: setup outcome is unknown (operation ${attemptId}). Nothing was revoked. Check \`void email domain status ${target.emailDomain}\` before starting another setup.`);
|
|
508
|
+
}
|
|
509
|
+
log.success("Token stored encrypted on the platform for this zone connection — nothing is kept on this machine.");
|
|
510
|
+
printEmailDomainOutcome(row);
|
|
511
|
+
if (row.operationStatus !== "completed") return exit(1);
|
|
512
|
+
}
|
|
513
|
+
function emailDomainStatusText(status) {
|
|
514
|
+
if (status === "ready") return import_picocolors.default.green("ready");
|
|
515
|
+
if (status === "pending") return import_picocolors.default.yellow("pending");
|
|
516
|
+
return status === "disabled" ? import_picocolors.default.yellow("disabled") : import_picocolors.default.red(status);
|
|
517
|
+
}
|
|
518
|
+
function printEmailDomainOutcome(row, projectRef) {
|
|
519
|
+
const lines = [
|
|
520
|
+
`Domain: ${row.domain}`,
|
|
521
|
+
`Managed by: ${row.managed_by === "admin" ? "administrator" : "project owner"}`,
|
|
522
|
+
...Object.entries(row.readiness).map(([name, value]) => `${name.padEnd(12)} ${value.status}${value.reason ? " — " + value.reason : ""}${value.observedAt ? " (" + value.observedAt + ")" : ""}`)
|
|
523
|
+
];
|
|
524
|
+
if (row.operationId) lines.push(`Operation: ${row.operationId}${row.operationStatus ? " · " + row.operationStatus : ""}`);
|
|
525
|
+
note(lines.join("\n"), "Email Domain");
|
|
526
|
+
if (row.error) log.error(row.error);
|
|
527
|
+
if (row.operationStatus === "unknown") {
|
|
528
|
+
log.warn(`Operation ${row.operationId} has an unknown outcome; inspect its status before retrying.`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (row.readiness.management.status === "expired" || row.readiness.management.status === "revoked") log.info(row.managed_by === "admin" ? `Ask your administrator to run \`void platform email domain-add ${row.domain} --project ${projectRef ?? row.projectId}\` with a renewed credential.` : `Run \`void email domain add ${row.domain}\` with a new scoped API token.`);
|
|
532
|
+
else if (row.readiness.inbound.status === "pending" || row.readiness.inbound.status === "drifted") log.info(row.managed_by === "admin" ? `Ask your administrator to run \`void platform email domain-sync ${row.domain}\` to reconcile setup.` : `Run \`void email domain sync ${row.domain}\` to reconcile setup.`);
|
|
533
|
+
}
|
|
534
|
+
async function emailDomainStatus(client, projectId, rawDomain) {
|
|
535
|
+
const domain = normalizeEmailDomainInput(rawDomain);
|
|
536
|
+
const row = await client.getEmailDomain(projectId, domain);
|
|
537
|
+
printEmailDomainOutcome(row, row.managed_by === "admin" ? (await client.getEmailSettings(projectId)).domains.projectSlug ?? projectId : void 0);
|
|
538
|
+
}
|
|
539
|
+
async function emailDomainList(client, projectId) {
|
|
540
|
+
const rows = await client.listEmailDomains(projectId);
|
|
541
|
+
if (rows.length === 0) {
|
|
542
|
+
log.info("No email domains. Run `void email domain add <domain>` to send and receive at your own domain on a Cloudflare zone.");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const width = Math.max(...rows.map((row) => row.domain.length));
|
|
546
|
+
const lines = rows.map((row) => ` ${row.domain.padEnd(width)} ${emailDomainStatusText(row.status)} ${row.managed_by === "admin" ? "administrator" : "project owner"}`);
|
|
547
|
+
note(lines.join("\n"), "Email Domains");
|
|
548
|
+
}
|
|
549
|
+
async function emailDomainSync(client, projectId, rawDomain) {
|
|
550
|
+
const domain = normalizeEmailDomainInput(rawDomain);
|
|
551
|
+
const row = await client.syncEmailDomain(projectId, domain);
|
|
552
|
+
printEmailDomainOutcome(row);
|
|
553
|
+
if (row.operationStatus !== "completed") throw new Error(`email: reconciliation ${row.operationStatus ?? "pending"}; inspect operation ${row.operationId ?? row.domain}`);
|
|
554
|
+
}
|
|
555
|
+
/** Rotation applies to every domain sharing this zone connection. */
|
|
556
|
+
async function emailDomainRotateSecret(client, projectId, rawDomain, deps = {}) {
|
|
557
|
+
const domain = normalizeEmailDomainInput(rawDomain);
|
|
558
|
+
const attemptId = crypto.randomUUID();
|
|
559
|
+
let row;
|
|
560
|
+
try {
|
|
561
|
+
row = await client.rotateEmailDomainSecret(projectId, domain, attemptId);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof PlatformApiError && [
|
|
564
|
+
400,
|
|
565
|
+
403,
|
|
566
|
+
404,
|
|
567
|
+
409
|
|
568
|
+
].includes(error.status)) throw error;
|
|
569
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
570
|
+
for (let attempt = 0; attempt < ADD_RECONCILE_ATTEMPTS; attempt++) {
|
|
571
|
+
if (attempt > 0) await sleep(ADD_RECONCILE_INTERVAL_MS);
|
|
572
|
+
let operation;
|
|
573
|
+
try {
|
|
574
|
+
operation = await client.getEmailOperation(projectId, attemptId);
|
|
575
|
+
} catch {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
note([
|
|
579
|
+
`Operation: ${operation.operationId}`,
|
|
580
|
+
`Status: ${operation.status}`,
|
|
581
|
+
`Inbound: ${operation.readiness.inbound.status}`,
|
|
582
|
+
`Outbound: ${operation.readiness.outbound.status}`,
|
|
583
|
+
`Credential: ${operation.readiness.management.status}`
|
|
584
|
+
].join("\n"), "Email Secret Rotation");
|
|
585
|
+
if (operation.status !== "completed") throw new Error(`email: secret rotation ${operation.status}; inspect operation ${operation.operationId}`);
|
|
586
|
+
log.success("Zone ingress secret rotated and verified on the deployed Worker.");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
throw new Error(`email: secret rotation outcome is unknown (operation key ${attemptId}); inspect \`void email domain status ${domain}\` before starting another rotation.`);
|
|
590
|
+
}
|
|
591
|
+
printEmailDomainOutcome(row);
|
|
592
|
+
if (row.operationStatus !== "completed") throw new Error(`email: secret rotation ${row.operationStatus ?? "pending"}; inspect operation ${row.operationId ?? row.domain}`);
|
|
593
|
+
log.success("Zone ingress secret rotated and verified on the deployed Worker.");
|
|
594
|
+
}
|
|
595
|
+
async function emailDomainRemove(client, projectId, rawDomain, options = {}) {
|
|
596
|
+
const domain = normalizeEmailDomainInput(rawDomain);
|
|
597
|
+
const result = await client.removeEmailDomain(projectId, domain);
|
|
598
|
+
log.info(`Operation: ${result.operationId} · ${result.operationStatus ?? "pending"}`);
|
|
599
|
+
if (!result.removed || result.operationStatus !== "completed") {
|
|
600
|
+
log.error(result.error ?? "Email cleanup is not complete. Its operation and resources remain recorded for reconciliation.");
|
|
601
|
+
(options.exit ?? ((code) => process.exit(code)))(1);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
log.success(`Removed ${domain}. Zone resources still used by other domains were retained.`);
|
|
605
|
+
}
|
|
606
|
+
async function runEmailDomainCommand(client, projectId, args) {
|
|
607
|
+
switch (args.domainVerb) {
|
|
608
|
+
case "add": return emailDomainAdd(client, projectId, args.domain, args.subdomain);
|
|
609
|
+
case "status": return emailDomainStatus(client, projectId, args.domain);
|
|
610
|
+
case "list": return emailDomainList(client, projectId);
|
|
611
|
+
case "sync": return emailDomainSync(client, projectId, args.domain);
|
|
612
|
+
case "rotate-secret": return emailDomainRotateSecret(client, projectId, args.domain);
|
|
613
|
+
case "remove": return emailDomainRemove(client, projectId, args.domain);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Wrangler discovers config files by walking parent directories. A standalone email command in
|
|
618
|
+
* an app with no root config must still run in an explicit empty config context, otherwise an
|
|
619
|
+
* unrelated ancestor config can select the wrong account. The real project path remains separate:
|
|
620
|
+
* setup writes there only after it succeeds, while status leaves the project untouched.
|
|
621
|
+
*/
|
|
622
|
+
function createEmailWranglerConfigContext(configPath) {
|
|
623
|
+
if (existsSync(configPath)) return {
|
|
624
|
+
path: configPath,
|
|
625
|
+
cleanup: () => {}
|
|
626
|
+
};
|
|
627
|
+
const directory = mkdtempSync(join(tmpdir(), "void-email-wrangler-"));
|
|
628
|
+
const path = join(directory, "wrangler.jsonc");
|
|
629
|
+
try {
|
|
630
|
+
writeFileSync(path, "{}\n", {
|
|
631
|
+
encoding: "utf8",
|
|
632
|
+
flag: "wx",
|
|
633
|
+
mode: 384
|
|
634
|
+
});
|
|
635
|
+
} catch (error) {
|
|
636
|
+
rmSync(directory, {
|
|
637
|
+
recursive: true,
|
|
638
|
+
force: true
|
|
639
|
+
});
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
path,
|
|
644
|
+
cleanup: () => rmSync(directory, {
|
|
645
|
+
recursive: true,
|
|
646
|
+
force: true
|
|
647
|
+
})
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* `void email setup --platform cloudflare` and `void email status --platform cloudflare`.
|
|
652
|
+
*
|
|
653
|
+
* `setup` is the deploy's step 3b-ii on its own: preflight → checklist → the one Enter →
|
|
654
|
+
* apply → write the root wrangler.jsonc. It exists for CI: a non-interactive deploy that
|
|
655
|
+
* finds email not ready prints the checklist and says to run this once, commit
|
|
656
|
+
* wrangler.jsonc, and redeploy. Rules and the catch-all stay wrangler's job — the next
|
|
657
|
+
* `void deploy --platform cloudflare` applies the plan from the written `addresses`. It
|
|
658
|
+
* is also the ONLY place a sending onboarding the committed binding remembers as refused
|
|
659
|
+
* (Workers Free) is retried — on its own prompt, after a Workers Paid upgrade; the deploy
|
|
660
|
+
* reads that row as settled and never retries it.
|
|
661
|
+
*
|
|
662
|
+
* `status` is the preflight alone, printed, exit 1 when not ready.
|
|
663
|
+
*/
|
|
664
|
+
async function runEmailCloudflareCommand(root, args, deps = {}) {
|
|
665
|
+
const rawExit = deps.exit ?? ((code) => process.exit(code));
|
|
666
|
+
const from = readConfig(root).email?.from;
|
|
667
|
+
if (!from) {
|
|
668
|
+
log.error("email: no sender configured. Add \"email\": { \"from\": \"you@mail.acme.com\" } to void.json — its host is the domain email is set up on.");
|
|
669
|
+
return rawExit(1);
|
|
670
|
+
}
|
|
671
|
+
const paths = readProjectPaths(root);
|
|
672
|
+
const handlers = scanEmailHandlersSync(paths.sourceRoot);
|
|
673
|
+
const configPath = discoverRootWranglerConfig(root);
|
|
674
|
+
const wranglerConfig = createEmailWranglerConfigContext(configPath);
|
|
675
|
+
try {
|
|
676
|
+
return await runEmailCloudflareCommandInConfig(root, args, {
|
|
677
|
+
from,
|
|
678
|
+
handlers
|
|
679
|
+
}, configPath, wranglerConfig.path, {
|
|
680
|
+
...deps,
|
|
681
|
+
exit: (code) => {
|
|
682
|
+
wranglerConfig.cleanup();
|
|
683
|
+
return rawExit(code);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
} finally {
|
|
687
|
+
wranglerConfig.cleanup();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function runEmailCloudflareCommandInConfig(root, args, project, configPath, wranglerConfigPath, deps) {
|
|
691
|
+
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
692
|
+
const print = deps.print ?? ((block) => log.message(block));
|
|
693
|
+
const fail = (message) => {
|
|
694
|
+
log.error(message);
|
|
695
|
+
return exit(1);
|
|
696
|
+
};
|
|
697
|
+
const { from, handlers } = project;
|
|
698
|
+
const rootConfig = parseRootWranglerConfig(configPath);
|
|
699
|
+
await loadProvisionModule();
|
|
700
|
+
let apiBaseUrl;
|
|
701
|
+
try {
|
|
702
|
+
apiBaseUrl = resolveDeployTransport(root, rootConfig);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
|
|
705
|
+
}
|
|
706
|
+
const accountId = readWranglerTopLevelString(rootConfig, "account_id") ?? (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || void 0);
|
|
707
|
+
if (!accountId) return fail([
|
|
708
|
+
"email: no Cloudflare account is pinned.",
|
|
709
|
+
"Set `account_id` in your root wrangler.jsonc, or export CLOUDFLARE_ACCOUNT_ID —",
|
|
710
|
+
"the same pin `void deploy --platform cloudflare` needs."
|
|
711
|
+
].join("\n"));
|
|
712
|
+
const workerName = readWranglerTopLevelString(rootConfig, "name") ?? deriveWorkerName(root);
|
|
713
|
+
const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl);
|
|
714
|
+
const getSessionBearer = deps.getSessionBearer ?? (() => runWranglerAuthToken(runWrangler, wranglerConfigPath));
|
|
715
|
+
const sendEmail = rootConfig.send_email;
|
|
716
|
+
const inputs = {
|
|
717
|
+
handlers,
|
|
718
|
+
from,
|
|
719
|
+
workerName,
|
|
720
|
+
accountId,
|
|
721
|
+
existingSendEmail: Array.isArray(sendEmail) ? sendEmail : null,
|
|
722
|
+
existingAddresses: rootConfig.addresses,
|
|
723
|
+
existingVars: rootConfig.vars && typeof rootConfig.vars === "object" && !Array.isArray(rootConfig.vars) ? rootConfig.vars : null,
|
|
724
|
+
configConflicts: findEmailConfigConflicts(rootConfig),
|
|
725
|
+
wranglerArgs: [
|
|
726
|
+
"-c",
|
|
727
|
+
wranglerConfigPath,
|
|
728
|
+
"--env="
|
|
729
|
+
],
|
|
730
|
+
apiBaseUrl,
|
|
731
|
+
runWrangler,
|
|
732
|
+
getSessionBearer,
|
|
733
|
+
fetchImpl: deps.fetchImpl ?? fetch,
|
|
734
|
+
dns: deps.dns ?? await createNodeEmailDns()
|
|
735
|
+
};
|
|
736
|
+
let pre;
|
|
737
|
+
try {
|
|
738
|
+
pre = await preflightEmail(inputs);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
|
|
741
|
+
}
|
|
742
|
+
print(renderEmailChecklist(pre));
|
|
743
|
+
if (args.subcommand === "status") {
|
|
744
|
+
print(renderAddressMap({
|
|
745
|
+
from,
|
|
746
|
+
routes: pre.derived.routes,
|
|
747
|
+
addresses: pre.rules.mine,
|
|
748
|
+
sendingReady: pre.sending.state === "ready",
|
|
749
|
+
pendingLabel: "(no rule yet)"
|
|
750
|
+
}));
|
|
751
|
+
if (pre.ready) {
|
|
752
|
+
log.success(`Email is set up on ${pre.domain}.`);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
log.warn(`Email is not set up on ${pre.domain} yet — run \`void email setup --platform cloudflare\`.`);
|
|
756
|
+
return exit(1);
|
|
757
|
+
}
|
|
758
|
+
if (pre.blockers.some((b) => b.scope === "all")) return fail("email: nothing was changed — fix the ✘ rows above and rerun.");
|
|
759
|
+
if (!pre.ready || pre.sendingRetryable) {
|
|
760
|
+
if (!(deps.isInteractive ?? !isWranglerNonInteractiveOrCI())) return fail("email: `void email setup` changes your Cloudflare account, so it needs an interactive terminal. Run it locally once, commit wrangler.jsonc, then redeploy.");
|
|
761
|
+
if (!await (deps.confirm ?? (async (message) => {
|
|
762
|
+
const answer = await confirm({
|
|
763
|
+
message,
|
|
764
|
+
initialValue: true
|
|
765
|
+
});
|
|
766
|
+
return !isCancel(answer) && answer === true;
|
|
767
|
+
}))(pre.ready ? `Onboard ${pre.domain} for Email Sending? Inbound already works; onboarding needs Workers Paid.` : `Set up email on ${pre.domain}?`)) {
|
|
768
|
+
log.info("Nothing changed.");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const out = await applyEmailSetup(pre, inputs);
|
|
773
|
+
if (out.lines.length > 0) print(out.lines.join("\n"));
|
|
774
|
+
try {
|
|
775
|
+
writeProvisionedWranglerConfig(root, out.patch, { configPath });
|
|
776
|
+
} catch (err) {
|
|
777
|
+
return fail(`email: could not update ${configPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
778
|
+
}
|
|
779
|
+
print(renderAddressMap({
|
|
780
|
+
from,
|
|
781
|
+
routes: pre.derived.routes,
|
|
782
|
+
addresses: out.patch.addresses ?? [],
|
|
783
|
+
sendingReady: out.sendingReady,
|
|
784
|
+
pendingLabel: "(no rule yet)"
|
|
785
|
+
}));
|
|
786
|
+
if (!out.inboundReady) {
|
|
787
|
+
const missing = !out.routingReady ? `Email Routing on ${pre.domain} is not ready` : !out.subaddressingReady ? `subaddressing on ${pre.apex} is still off (support+anything@ would not reach support@)` : "`addresses` was withheld";
|
|
788
|
+
log.warn(`Email is not set up on ${pre.domain} yet — ${missing}; no routing rule is created until it is. Finish the step above, then rerun \`void email setup --platform cloudflare\`.`);
|
|
789
|
+
return exit(1);
|
|
790
|
+
}
|
|
791
|
+
if ((out.patch.addresses ?? []).length > 0) log.info("Routing rules are created by the next `void deploy --platform cloudflare`, which applies the Email Routing plan from `addresses`.");
|
|
792
|
+
log.success(`Email set up on ${pre.domain}.`);
|
|
793
|
+
}
|
|
794
|
+
//#endregion
|
|
795
|
+
export { runEmailCommand };
|