void 0.20.1 → 0.20.2
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-gniL2fNt.mjs} +5 -4
- package/dist/auth-link-NZdjCmSc.mjs +28 -0
- package/dist/{build-cmd-CJvZvPQO.mjs → build-cmd-sI18tX_O.mjs} +3 -3
- package/dist/{cache-BlNeQjuP.mjs → cache-IHn5MwBC.mjs} +3 -3
- package/dist/{cancel-deploy-CmlAZ9P6.mjs → cancel-deploy-C5qTOdLi.mjs} +3 -3
- package/dist/cf-access-DRsQRe6k.mjs +75 -0
- package/dist/cli/cli.mjs +308 -1958
- package/dist/cli/env-schema-probe.mjs +11 -2
- package/dist/{client-Clirrol3.mjs → client-dHfSJvAN.mjs} +300 -81
- 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-Bfk31O_8.mjs} +6 -6
- package/dist/{create-project-ChGZ1DFd.mjs → create-project-Bk9Z0-Jg.mjs} +6 -5
- package/dist/{db-D2d_mUsB.mjs → db-BkRoptAt.mjs} +47 -30
- package/dist/{delete-D8GigDk8.mjs → delete-DouASY9P.mjs} +3 -3
- package/dist/{deploy-iXZ3F0N6.mjs → deploy-DTaWUS1S.mjs} +121 -110
- package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
- package/dist/{domain-B1VmoSr0.mjs → domain-1RhhOVrC.mjs} +4 -4
- package/dist/email-C-lGh51B.mjs +795 -0
- package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
- package/dist/{env-BcQzYgoG.mjs → env-DJHsPE7Z.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-PW7ZnWTp.mjs} +3 -3
- 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-BWZ7q5Z4.mjs} +11 -11
- package/dist/{link-RMdgjF1v.mjs → link-Rmvu2Wl_.mjs} +4 -4
- package/dist/{list-3F52R_yO.mjs → list-DEE2S6mY.mjs} +4 -4
- package/dist/{login-pV69H-ZO.mjs → login-Uvferzmm.mjs} +28 -10
- package/dist/{logs-DFHHD6wE.mjs → logs-27FenuiC.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-CjOTmAYE.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-DrbQXXiW.mjs +368 -0
- package/dist/platform-auth-protection-Bhtvp0B_.mjs +219 -0
- package/dist/platform-auth-recovery-CeOKGVeJ.mjs +310 -0
- package/dist/{platform-cmd-DxJ2FRwR.mjs → platform-cmd-BFhieCdV.mjs} +16 -6
- package/dist/{platform-domain-ChvbJkdy.mjs → platform-domain-C74PULqV.mjs} +4 -4
- package/dist/{platform-lifecycle-DN4MzJF_.mjs → platform-lifecycle-BwAIgz-t.mjs} +1667 -191
- package/dist/{platform-management-Db2PXw0B.mjs → platform-management-COogu_Se.mjs} +33 -7
- package/dist/{platform-recovery-C_YO-tIs.mjs → platform-recovery-ewqLefp1.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-DmZK9Hxf.mjs} +32 -14
- package/dist/project-team-D8jOJMUJ.mjs +130 -0
- package/dist/project-token-DA34bf-C.mjs +75 -0
- package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
- package/dist/{requests-BcKOVpRg.mjs → requests-CUExwGQQ.mjs} +3 -3
- package/dist/{rollback-Bx85-0xh.mjs → rollback-CDNGU1gr.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 +5 -3
- package/dist/{secret-ByhJ9AMl.mjs → secret-Bzzi2e9E.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 +2 -0
- package/skills/void/docs/guide/email.md +102 -110
- package/skills/void/docs/guide/platform-administration.md +214 -2
- package/skills/void/docs/guide/platform-development.md +64 -2
- package/skills/void/docs/guide/project-collaboration.md +94 -0
- package/skills/void/docs/guide/self-hosted-platform.md +184 -30
- package/skills/void/docs/reference/cli.md +249 -21
- package/dist/cf-access-AJ1ehiFR.mjs +0 -42
- package/dist/cf-access-DsSsZUPr.mjs +0 -67
- package/dist/email-uKyQYUVY.mjs +0 -1016
package/dist/email-uKyQYUVY.mjs
DELETED
|
@@ -1,1016 +0,0 @@
|
|
|
1
|
-
import { A as password, D as log, F as isCancel, O as note, c as import_picocolors, i as createSpinner, w as confirm } from "./output-tFQLLj26.mjs";
|
|
2
|
-
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-iXZ3F0N6.mjs";
|
|
3
|
-
import { n as readProjectPaths } from "./project-paths-SK8nMHPp.mjs";
|
|
4
|
-
import { c as getToken, d as openBrowser, i as createCodeVerifier, r as createCodeChallenge, x as readProjectConfig } from "./auth-W9WII-mN.mjs";
|
|
5
|
-
import { k as writeProvisionedWranglerConfig } from "./wrangler--imS8n0d.mjs";
|
|
6
|
-
import { f as readConfig } from "./config-BQFq7QvD.mjs";
|
|
7
|
-
import { a as scanEmailHandlersSync } from "./email-Ce6SQq-i.mjs";
|
|
8
|
-
import { n as PlatformClient, t as PlatformApiError } from "./client-Clirrol3.mjs";
|
|
9
|
-
import { randomBytes } from "node:crypto";
|
|
10
|
-
import { execSync } from "node:child_process";
|
|
11
|
-
import { parseRootWranglerConfig, readWranglerTopLevelString } from "@void/deploy-cloudflare";
|
|
12
|
-
//#region src/cli/cf-oauth.ts
|
|
13
|
-
/**
|
|
14
|
-
* The Cloudflare OAuth grant on wrangler's public PKCE client (design doc
|
|
15
|
-
* 0078, "The OAuth grant on wrangler's client"; task contract 8) — the
|
|
16
|
-
* primary credential grant for `void email domain add`. The customer clicks
|
|
17
|
-
* Allow on Cloudflare's hosted consent page, the loopback server below
|
|
18
|
-
* receives the code, exchanges it at the token endpoint, and returns the
|
|
19
|
-
* grant's refresh token.
|
|
20
|
-
*
|
|
21
|
-
* The refresh token is held in memory only: the command POSTs it to the
|
|
22
|
-
* platform as its first use and drops it. It is never written to disk, the
|
|
23
|
-
* keychain, a log line, or an error message.
|
|
24
|
-
*
|
|
25
|
-
* The client id is wrangler's own public client (no secret), which
|
|
26
|
-
* Cloudflare can rotate or revoke any day — so the template-link + paste
|
|
27
|
-
* path in `email.ts` is the permanent fallback, not a legacy branch. The
|
|
28
|
-
* consent page is Cloudflare's and names Wrangler; it cannot say Void, so
|
|
29
|
-
* the warning line is printed here before the browser opens.
|
|
30
|
-
*/
|
|
31
|
-
/** wrangler's public PKCE OAuth client id (no secret). Not ours — see the header. */
|
|
32
|
-
const CF_OAUTH_CLIENT_ID = "54d11594-84e4-41aa-b438-e81b8fa78ee7";
|
|
33
|
-
const CF_OAUTH_AUTHORIZE_URL = "https://dash.cloudflare.com/oauth2/auth";
|
|
34
|
-
const CF_OAUTH_TOKEN_URL = "https://dash.cloudflare.com/oauth2/token";
|
|
35
|
-
const CF_OAUTH_REVOKE_URL = "https://dash.cloudflare.com/oauth2/revoke";
|
|
36
|
-
/**
|
|
37
|
-
* The redirect registered on wrangler's client. Fixed port and path: the
|
|
38
|
-
* consent flow redirects here and only here, so the loopback server binds
|
|
39
|
-
* exactly :8976 and answers exactly /oauth/callback — unlike the platform
|
|
40
|
-
* login's random-port /callback in auth.ts, which cannot serve this flow.
|
|
41
|
-
*/
|
|
42
|
-
const CF_OAUTH_CALLBACK_PORT = 8976;
|
|
43
|
-
const CF_OAUTH_REDIRECT_URI = `http://localhost:${CF_OAUTH_CALLBACK_PORT}/oauth/callback`;
|
|
44
|
-
/** The scopes the email-domain setup needs; `offline_access` is what yields the refresh token. */
|
|
45
|
-
const CF_OAUTH_SCOPE = "zone:read email_routing:write email_sending:write workers_scripts:write offline_access";
|
|
46
|
-
/**
|
|
47
|
-
* Revoke a grant at Cloudflare — RFC 7009 on wrangler's public client, the
|
|
48
|
-
* same form wrangler's `logout` posts (a public client authenticates with
|
|
49
|
-
* the token itself; no Authorization header). Never throws, never logs the
|
|
50
|
-
* token; the boolean is whether Cloudflare CONFIRMED the revocation (200 —
|
|
51
|
-
* CF answers 200 even for a bogus token), so `false` means the grant's
|
|
52
|
-
* state is unknown and it may still be live: callers that print an outcome
|
|
53
|
-
* must not claim a revocation they cannot prove. For when the CLI holds a
|
|
54
|
-
* grant it will not hand to the platform: a consent exchange that
|
|
55
|
-
* completed after the flow settled (fire-and-forget there — the flow is
|
|
56
|
-
* settled), or an add request that provably never persisted one.
|
|
57
|
-
*/
|
|
58
|
-
async function revokeCfOauthGrant(refreshToken) {
|
|
59
|
-
try {
|
|
60
|
-
return (await fetch(CF_OAUTH_REVOKE_URL, {
|
|
61
|
-
method: "POST",
|
|
62
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
63
|
-
body: new URLSearchParams({
|
|
64
|
-
token: refreshToken,
|
|
65
|
-
token_type_hint: "refresh_token",
|
|
66
|
-
client_id: CF_OAUTH_CLIENT_ID
|
|
67
|
-
}).toString()
|
|
68
|
-
})).ok;
|
|
69
|
-
} catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
/** The consent URL the browser opens — PKCE S256, state-bound to the loopback server. */
|
|
74
|
-
function buildCfOauthAuthorizeUrl(params) {
|
|
75
|
-
const url = new URL(CF_OAUTH_AUTHORIZE_URL);
|
|
76
|
-
url.searchParams.set("response_type", "code");
|
|
77
|
-
url.searchParams.set("client_id", CF_OAUTH_CLIENT_ID);
|
|
78
|
-
url.searchParams.set("redirect_uri", CF_OAUTH_REDIRECT_URI);
|
|
79
|
-
url.searchParams.set("scope", CF_OAUTH_SCOPE);
|
|
80
|
-
url.searchParams.set("state", params.state);
|
|
81
|
-
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
82
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
83
|
-
return url.href;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Exchange the authorization code at Cloudflare's token endpoint. The
|
|
87
|
-
* response body is never logged and never copied into an error — it carries
|
|
88
|
-
* token material on success, so failure messages name only the HTTP status.
|
|
89
|
-
*/
|
|
90
|
-
async function exchangeCfOauthCode(code, codeVerifier) {
|
|
91
|
-
const res = await fetch(CF_OAUTH_TOKEN_URL, {
|
|
92
|
-
method: "POST",
|
|
93
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
94
|
-
body: new URLSearchParams({
|
|
95
|
-
grant_type: "authorization_code",
|
|
96
|
-
code,
|
|
97
|
-
redirect_uri: CF_OAUTH_REDIRECT_URI,
|
|
98
|
-
client_id: CF_OAUTH_CLIENT_ID,
|
|
99
|
-
code_verifier: codeVerifier
|
|
100
|
-
}).toString()
|
|
101
|
-
});
|
|
102
|
-
if (!res.ok) throw new Error(`cf-oauth: Cloudflare's token exchange failed (HTTP ${res.status}).`);
|
|
103
|
-
let body;
|
|
104
|
-
try {
|
|
105
|
-
body = await res.json();
|
|
106
|
-
} catch {
|
|
107
|
-
throw new Error("cf-oauth: Cloudflare's token exchange returned an invalid response.");
|
|
108
|
-
}
|
|
109
|
-
const refreshToken = body && typeof body === "object" && "refresh_token" in body ? body.refresh_token : void 0;
|
|
110
|
-
if (typeof refreshToken !== "string" || refreshToken.length === 0) throw new Error("cf-oauth: Cloudflare's answer carried no refresh token.");
|
|
111
|
-
return { refreshToken };
|
|
112
|
-
}
|
|
113
|
-
/** Same minimal callback page the platform login renders (auth.ts keeps its own copy). */
|
|
114
|
-
function page(title, message) {
|
|
115
|
-
return `<!DOCTYPE html>
|
|
116
|
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
117
|
-
<title>${title} - Void</title>
|
|
118
|
-
<style>:root{color-scheme:light dark}*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#111}.card{text-align:center;padding:3rem}h1{font-size:1.5rem;font-weight:600;margin-bottom:.75rem}p{color:#666;font-size:1rem}@media (prefers-color-scheme:dark){body{background:#0a0a0a;color:#fafafa}p{color:#a3a3a3}}</style>
|
|
119
|
-
</head><body><div class="card"><h1>${title}</h1><p>${message}</p></div></body></html>`;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Run the grant: bind the loopback server on :8976, warn about the
|
|
123
|
-
* Wrangler-named consent page, open the browser, and resolve with the
|
|
124
|
-
* refresh token once the code has been exchanged INSIDE the request handler
|
|
125
|
-
* (so the browser only sees the success page after the exchange succeeded).
|
|
126
|
-
*
|
|
127
|
-
* Rejects on a busy port, a denied consent, a failed exchange, an abort
|
|
128
|
-
* (`signal` — the command aborts when Enter switches to the token path), or
|
|
129
|
-
* the timeout; the caller falls back to the template-link path on any of
|
|
130
|
-
* them. `onUrl` fires with the authorize URL once the server is listening,
|
|
131
|
-
* so the command can show it as the manual fallback when the browser fails
|
|
132
|
-
* to open. The server is always closed before settling.
|
|
133
|
-
*/
|
|
134
|
-
async function runCfOauthGrant(options) {
|
|
135
|
-
const http = await import("node:http");
|
|
136
|
-
const { onUrl, signal } = options;
|
|
137
|
-
const timeoutMs = options.timeoutMs ?? 3e5;
|
|
138
|
-
const execFile = options.execFile ?? (await import("node:child_process")).execFile;
|
|
139
|
-
if (signal?.aborted) throw new Error("cf-oauth: cancelled.");
|
|
140
|
-
return new Promise((resolve, reject) => {
|
|
141
|
-
let settled = false;
|
|
142
|
-
const state = randomBytes(32).toString("hex");
|
|
143
|
-
const codeVerifier = createCodeVerifier();
|
|
144
|
-
const codeChallenge = createCodeChallenge(codeVerifier);
|
|
145
|
-
const onAbort = () => fail(/* @__PURE__ */ new Error("cf-oauth: cancelled."));
|
|
146
|
-
const fail = (error) => {
|
|
147
|
-
if (settled) return;
|
|
148
|
-
settled = true;
|
|
149
|
-
server.close();
|
|
150
|
-
signal?.removeEventListener("abort", onAbort);
|
|
151
|
-
reject(error);
|
|
152
|
-
};
|
|
153
|
-
const succeed = (grant) => {
|
|
154
|
-
if (settled) return;
|
|
155
|
-
settled = true;
|
|
156
|
-
server.close();
|
|
157
|
-
signal?.removeEventListener("abort", onAbort);
|
|
158
|
-
resolve(grant);
|
|
159
|
-
};
|
|
160
|
-
const cancelled = (res) => {
|
|
161
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
162
|
-
res.end(page("Authorization cancelled", "This sign-in was cancelled in the terminal. You can close this tab."));
|
|
163
|
-
};
|
|
164
|
-
const server = http.createServer(async (req, res) => {
|
|
165
|
-
const url = new URL(req.url, "http://localhost");
|
|
166
|
-
if (url.pathname !== "/oauth/callback") {
|
|
167
|
-
res.writeHead(404);
|
|
168
|
-
res.end("Not found");
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (url.searchParams.get("state") !== state) {
|
|
172
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
173
|
-
res.end(page("Authorization failed", "Invalid callback. Please try again."));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const cfError = url.searchParams.get("error");
|
|
177
|
-
if (cfError) {
|
|
178
|
-
const message = cfError === "access_denied" ? "The Cloudflare consent was denied." : `Cloudflare returned an error (${cfError}).`;
|
|
179
|
-
res.writeHead(403, { "Content-Type": "text/html" });
|
|
180
|
-
res.end(page("Authorization failed", message));
|
|
181
|
-
fail(/* @__PURE__ */ new Error(`cf-oauth: ${message}`));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
const code = url.searchParams.get("code");
|
|
185
|
-
if (!code) {
|
|
186
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
187
|
-
res.end(page("Authorization failed", "No authorization code received. Please try again."));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
let grant;
|
|
191
|
-
try {
|
|
192
|
-
grant = await exchangeCfOauthCode(code, codeVerifier);
|
|
193
|
-
} catch (err) {
|
|
194
|
-
if (settled) {
|
|
195
|
-
cancelled(res);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
199
|
-
res.end(page("Authorization failed", "Could not complete the Cloudflare grant. Please try again."));
|
|
200
|
-
fail(err instanceof Error ? err : /* @__PURE__ */ new Error("cf-oauth: Cloudflare's token exchange failed."));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (settled) {
|
|
204
|
-
revokeCfOauthGrant(grant.refreshToken);
|
|
205
|
-
cancelled(res);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
209
|
-
res.end(page("Cloudflare connected", "You can close this tab and return to the terminal."));
|
|
210
|
-
succeed(grant);
|
|
211
|
-
});
|
|
212
|
-
server.on("connection", (socket) => socket.unref());
|
|
213
|
-
server.on("error", (err) => {
|
|
214
|
-
const code = err.code;
|
|
215
|
-
fail(/* @__PURE__ */ new Error(`cf-oauth: could not start the local callback server on port ${CF_OAUTH_CALLBACK_PORT}${code ? ` (${code})` : ""}.`));
|
|
216
|
-
});
|
|
217
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
218
|
-
server.listen(CF_OAUTH_CALLBACK_PORT, "127.0.0.1", () => {
|
|
219
|
-
log.warn("Opening Cloudflare's consent page — it names Wrangler; that is expected (Void borrows wrangler's OAuth client for this one-click grant).");
|
|
220
|
-
const authorizeUrl = buildCfOauthAuthorizeUrl({
|
|
221
|
-
state,
|
|
222
|
-
codeChallenge
|
|
223
|
-
});
|
|
224
|
-
onUrl?.(authorizeUrl);
|
|
225
|
-
openBrowser(authorizeUrl, execFile);
|
|
226
|
-
});
|
|
227
|
-
setTimeout(() => {
|
|
228
|
-
fail(/* @__PURE__ */ new Error("cf-oauth: the consent timed out."));
|
|
229
|
-
}, timeoutMs).unref();
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
//#endregion
|
|
233
|
-
//#region src/cli/email.ts
|
|
234
|
-
async function runEmailCommand(root, args) {
|
|
235
|
-
if (args.platform === "cloudflare") return runEmailCloudflareCommand(root, args);
|
|
236
|
-
if (args.subcommand === "setup") {
|
|
237
|
-
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.");
|
|
238
|
-
process.exit(1);
|
|
239
|
-
}
|
|
240
|
-
if (args.subcommand === "status") {
|
|
241
|
-
log.error("email: `void email status` is available with `--platform cloudflare` today. On the Void platform use `void email usage` and `void email destinations`.");
|
|
242
|
-
process.exit(1);
|
|
243
|
-
}
|
|
244
|
-
const token = getToken(root);
|
|
245
|
-
if (!token) {
|
|
246
|
-
log.error("Not logged in. Run `void auth login` first.");
|
|
247
|
-
process.exit(1);
|
|
248
|
-
}
|
|
249
|
-
const client = new PlatformClient(token, { root });
|
|
250
|
-
const requestedSlug = args.projectSlug ?? process.env.VOID_PROJECT?.trim();
|
|
251
|
-
let projectId;
|
|
252
|
-
if (requestedSlug) {
|
|
253
|
-
const project = (await client.listProjects()).find((p) => p.slug === requestedSlug);
|
|
254
|
-
if (!project) {
|
|
255
|
-
log.error(`project: No project found with slug '${requestedSlug}'.`);
|
|
256
|
-
process.exit(1);
|
|
257
|
-
}
|
|
258
|
-
projectId = project.id;
|
|
259
|
-
} else {
|
|
260
|
-
const config = readProjectConfig(root);
|
|
261
|
-
if (!config) {
|
|
262
|
-
log.error("No linked project. Run `void project link` or pass --project <name>.");
|
|
263
|
-
process.exit(1);
|
|
264
|
-
}
|
|
265
|
-
projectId = config.projectId;
|
|
266
|
-
}
|
|
267
|
-
switch (args.subcommand) {
|
|
268
|
-
case "usage": return emailUsage(client, projectId);
|
|
269
|
-
case "logs": return emailLogs(client, projectId, args.limit);
|
|
270
|
-
case "destinations": return emailDestinations(client, projectId);
|
|
271
|
-
case "allow": return emailAllow(client, projectId, args.address);
|
|
272
|
-
case "disallow": return emailDisallow(client, projectId, args.address);
|
|
273
|
-
case "domain": return runEmailDomainCommand(client, projectId, args);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
async function emailUsage(client, projectId) {
|
|
277
|
-
const usage = await client.getEmailUsage(projectId);
|
|
278
|
-
const status = usage.suspended ? import_picocolors.default.red("SUSPENDED") : import_picocolors.default.green("active");
|
|
279
|
-
const lines = [
|
|
280
|
-
`Month: ${usage.month}`,
|
|
281
|
-
`Status: ${status}`,
|
|
282
|
-
`Outbound: ${usage.sent} / ${usage.limit} (${usage.remaining} remaining)`,
|
|
283
|
-
`Inbound: ${usage.received}`
|
|
284
|
-
];
|
|
285
|
-
note(lines.join("\n"), "Email Usage");
|
|
286
|
-
}
|
|
287
|
-
async function emailDestinations(client, projectId) {
|
|
288
|
-
const rows = await client.listEmailDestinations(projectId);
|
|
289
|
-
if (rows.length === 0) {
|
|
290
|
-
log.info("No verified destinations. Run `void email allow <address>` to add one — Cloudflare will email the recipient with a verification link.");
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
const lines = rows.map((d) => {
|
|
294
|
-
const status = d.status === "verified" ? import_picocolors.default.green("verified") : d.status === "pending" ? import_picocolors.default.yellow("pending") : import_picocolors.default.red("failed");
|
|
295
|
-
return ` ${d.address.padEnd(40)} ${status}`;
|
|
296
|
-
});
|
|
297
|
-
note(lines.join("\n"), "Email Destinations");
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* `void email allow <address>` — exported for the unit test; the command
|
|
301
|
-
* entry (`runEmailCommand`) resolves the client and project first.
|
|
302
|
-
*/
|
|
303
|
-
async function emailAllow(client, projectId, address) {
|
|
304
|
-
const dest = await client.addEmailDestination(projectId, address);
|
|
305
|
-
const sent = dest.verificationSent !== false && (dest.created || dest.platformVerificationPending);
|
|
306
|
-
if (dest.status === "verified") log.success(`${dest.address} is already verified.`);
|
|
307
|
-
else if (sent) {
|
|
308
|
-
log.success(`Verification email sent to ${dest.address}.`);
|
|
309
|
-
log.info("They click the link in their inbox — no Void or Cloudflare account needed. `void email destinations` to poll status.");
|
|
310
|
-
} 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)}`);
|
|
311
|
-
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)}`);
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Cloudflare has no API to resend its verification mail; the only fresh link
|
|
315
|
-
* is a fresh registration, and the platform deletes the Cloudflare-side
|
|
316
|
-
* address only once no project still lists it.
|
|
317
|
-
*/
|
|
318
|
-
function freshLinkHint(address) {
|
|
319
|
-
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.`;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* `void email disallow <address>` — exported for the unit test; the command
|
|
323
|
-
* entry (`runEmailCommand`) resolves the client and project first.
|
|
324
|
-
*
|
|
325
|
-
* No deploy is involved: the api tightens the proxy's `email-allowlist`
|
|
326
|
-
* KV mirror inside the DELETE (and answers 503 if that write fails), and
|
|
327
|
-
* the proxy re-reads the mirror with a 60s `cacheTtl` on both the send
|
|
328
|
-
* path and inbound forward/reply — so the address is refused once that
|
|
329
|
-
* per-colo cache turns over.
|
|
330
|
-
*/
|
|
331
|
-
async function emailDisallow(client, projectId, address) {
|
|
332
|
-
const dest = (await client.listEmailDestinations(projectId)).find((d) => d.address.toLowerCase() === address.toLowerCase());
|
|
333
|
-
if (!dest) {
|
|
334
|
-
log.error(`destination: '${address}' is not in the project's allowlist.`);
|
|
335
|
-
process.exit(1);
|
|
336
|
-
}
|
|
337
|
-
await client.removeEmailDestination(projectId, dest.id);
|
|
338
|
-
log.success(`Removed ${dest.address}. Sends to it are refused within about a minute.`);
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* `void email logs` — exported for the unit test; the command entry
|
|
342
|
-
* (`runEmailCommand`) resolves the client and project first.
|
|
343
|
-
*/
|
|
344
|
-
async function emailLogs(client, projectId, limit) {
|
|
345
|
-
const result = await client.getEmailLogs(projectId, limit);
|
|
346
|
-
if (result.pending) {
|
|
347
|
-
log.info(result.note ?? "Persistent log storage is not yet wired up.");
|
|
348
|
-
log.info("Email activity logs are not available yet. Console output from your email handler appears in `void project logs`.");
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (result.logs.length === 0) {
|
|
352
|
-
log.info("No email activity recorded.");
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const lines = result.logs.map((entry) => {
|
|
356
|
-
const ts = new Date(entry.timestamp).toISOString();
|
|
357
|
-
const dir = entry.direction === "sent" ? "→" : "←";
|
|
358
|
-
const subject = entry.subject ? ` "${entry.subject}"` : "";
|
|
359
|
-
return `${ts} ${dir} ${entry.from} → ${entry.to} ${entry.status}${subject}`;
|
|
360
|
-
});
|
|
361
|
-
note(lines.join("\n"), "Email Logs");
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* The dashboard template link's permission groups (contract 8). Every key
|
|
365
|
-
* rides every link — there is no apex-only key, because `Zone Settings Edit`
|
|
366
|
-
* gates the routing POST on both paths; `zone: read` is what the zone lookup
|
|
367
|
-
* needs. URL-encoded by `URL.searchParams`.
|
|
368
|
-
*/
|
|
369
|
-
const CF_TOKEN_TEMPLATE_PERMISSION_GROUPS = [
|
|
370
|
-
{
|
|
371
|
-
key: "zone",
|
|
372
|
-
type: "read"
|
|
373
|
-
},
|
|
374
|
-
{
|
|
375
|
-
key: "email_routing_rule",
|
|
376
|
-
type: "edit"
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
key: "email_sending",
|
|
380
|
-
type: "edit"
|
|
381
|
-
},
|
|
382
|
-
{
|
|
383
|
-
key: "workers_scripts",
|
|
384
|
-
type: "edit"
|
|
385
|
-
},
|
|
386
|
-
{
|
|
387
|
-
key: "zone_settings",
|
|
388
|
-
type: "edit"
|
|
389
|
-
}
|
|
390
|
-
];
|
|
391
|
-
/** The three-click template link — the fallback grant path (0078, "Creating the token in three clicks"). */
|
|
392
|
-
function buildCfApiTokenTemplateUrl(domain) {
|
|
393
|
-
const url = new URL("https://dash.cloudflare.com/profile/api-tokens");
|
|
394
|
-
url.searchParams.set("permissionGroupKeys", JSON.stringify(CF_TOKEN_TEMPLATE_PERMISSION_GROUPS));
|
|
395
|
-
url.searchParams.set("accountId", "*");
|
|
396
|
-
url.searchParams.set("zoneId", "all");
|
|
397
|
-
url.searchParams.set("name", `Void email — ${domain}`);
|
|
398
|
-
return url.href;
|
|
399
|
-
}
|
|
400
|
-
const CFUT_TOKEN_PATTERN = /^cfut_[A-Za-z0-9_-]{44,64}$/;
|
|
401
|
-
const LEGACY_CF_TOKEN_PATTERN = /^[A-Za-z0-9_-]{40}$/;
|
|
402
|
-
const REFUSED_TOKEN_PREFIXES = [
|
|
403
|
-
"ghp_",
|
|
404
|
-
"github_pat_",
|
|
405
|
-
"sk-",
|
|
406
|
-
"xox",
|
|
407
|
-
"cfk_"
|
|
408
|
-
];
|
|
409
|
-
/** Shape-only classification of a clipboard or pasted value. Never logs the value. */
|
|
410
|
-
function classifyCfApiTokenShape(value) {
|
|
411
|
-
const token = value.trim();
|
|
412
|
-
if (REFUSED_TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix))) return "refused";
|
|
413
|
-
if (CFUT_TOKEN_PATTERN.test(token)) return "cfut";
|
|
414
|
-
if (LEGACY_CF_TOKEN_PATTERN.test(token)) return "legacy";
|
|
415
|
-
return "unknown";
|
|
416
|
-
}
|
|
417
|
-
function readClipboard() {
|
|
418
|
-
const platform = process.platform;
|
|
419
|
-
const command = platform === "darwin" ? "pbpaste" : platform === "win32" ? "powershell -NoProfile -Command Get-Clipboard" : "xclip -o -selection clipboard";
|
|
420
|
-
try {
|
|
421
|
-
return execSync(command, {
|
|
422
|
-
encoding: "utf8",
|
|
423
|
-
stdio: [
|
|
424
|
-
"ignore",
|
|
425
|
-
"pipe",
|
|
426
|
-
"ignore"
|
|
427
|
-
]
|
|
428
|
-
});
|
|
429
|
-
} catch {
|
|
430
|
-
if (platform !== "darwin" && platform !== "win32") try {
|
|
431
|
-
return execSync("wl-paste", {
|
|
432
|
-
encoding: "utf8",
|
|
433
|
-
stdio: [
|
|
434
|
-
"ignore",
|
|
435
|
-
"pipe",
|
|
436
|
-
"ignore"
|
|
437
|
-
]
|
|
438
|
-
});
|
|
439
|
-
} catch {}
|
|
440
|
-
return null;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
/** `pbcopy </dev/null` and the platform equivalents — best-effort, never throws. */
|
|
444
|
-
function clearClipboard() {
|
|
445
|
-
const platform = process.platform;
|
|
446
|
-
try {
|
|
447
|
-
if (platform === "darwin") execSync("pbcopy", { input: "" });
|
|
448
|
-
else if (platform === "win32") execSync("clip", { input: "" });
|
|
449
|
-
else try {
|
|
450
|
-
execSync("xclip -selection clipboard", { input: "" });
|
|
451
|
-
} catch {
|
|
452
|
-
execSync("wl-copy", { input: "" });
|
|
453
|
-
}
|
|
454
|
-
} catch {}
|
|
455
|
-
}
|
|
456
|
-
/**
|
|
457
|
-
* The CLI-side active check from 0078's clipboard sequence. 'unknown' means
|
|
458
|
-
* the check could not run (no network, a CF error) — the caller proceeds and
|
|
459
|
-
* lets the platform verify authoritatively, never treating a network blip as
|
|
460
|
-
* a bad token. The token itself is never logged.
|
|
461
|
-
*/
|
|
462
|
-
async function verifyCfApiToken(token) {
|
|
463
|
-
let res;
|
|
464
|
-
try {
|
|
465
|
-
res = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", { headers: { authorization: `Bearer ${token}` } });
|
|
466
|
-
} catch {
|
|
467
|
-
return "unknown";
|
|
468
|
-
}
|
|
469
|
-
if (res.status === 401 || res.status === 403) return "inactive";
|
|
470
|
-
if (!res.ok) return "unknown";
|
|
471
|
-
try {
|
|
472
|
-
return (await res.json()).result?.status === "active" ? "active" : "inactive";
|
|
473
|
-
} catch {
|
|
474
|
-
return "unknown";
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
/** Mirror of the api's normalization (`normalizeEmailDomain`). */
|
|
478
|
-
function normalizeEmailDomainInput(raw) {
|
|
479
|
-
return raw.trim().toLowerCase().replace(/\.$/, "");
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
|
-
* Same DNS-hostname shape the api enforces — checked here so junk fails
|
|
483
|
-
* before a grant is ever requested, not after. Real validation stays the
|
|
484
|
-
* platform's zone lookup.
|
|
485
|
-
*/
|
|
486
|
-
function isValidEmailDomainInput(domain) {
|
|
487
|
-
return /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/.test(domain);
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Where `--subdomain` lands: a bare label nests under the positional domain
|
|
491
|
-
* (`mx` → `mx.acme.com`), a full host is taken as-is but must stay under it.
|
|
492
|
-
* Throws on anything else — the user named a domain this add cannot register.
|
|
493
|
-
*/
|
|
494
|
-
function resolveEmailDomainTarget(domain, subdomain) {
|
|
495
|
-
if (subdomain === void 0) return {
|
|
496
|
-
emailDomain: domain,
|
|
497
|
-
forced: false
|
|
498
|
-
};
|
|
499
|
-
const candidate = subdomain.trim().toLowerCase().replace(/\.$/, "");
|
|
500
|
-
const resolved = candidate.includes(".") ? candidate : `${candidate}.${domain}`;
|
|
501
|
-
if (!isValidEmailDomainInput(resolved)) throw new Error(`email: invalid --subdomain value '${subdomain}'.`);
|
|
502
|
-
if (resolved === domain || !resolved.endsWith(`.${domain}`)) throw new Error(`email: --subdomain must name a subdomain of ${domain} (got '${subdomain}').`);
|
|
503
|
-
return {
|
|
504
|
-
emailDomain: resolved,
|
|
505
|
-
forced: true
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Local public-DNS MX read. [] = no records, null = could not read. The
|
|
510
|
-
* local answer only steers the default and the render — the platform
|
|
511
|
-
* re-checks over public DNS and its answer is authoritative, so a
|
|
512
|
-
* split-horizon resolver never blocks a valid setup here.
|
|
513
|
-
*/
|
|
514
|
-
async function lookupMxForRender(dns, name) {
|
|
515
|
-
try {
|
|
516
|
-
return (await dns.resolveMx(name)).slice().sort((a, b) => a.priority - b.priority).map((r) => r.exchange.replace(/\.$/, "").toLowerCase());
|
|
517
|
-
} catch (err) {
|
|
518
|
-
const code = err?.code;
|
|
519
|
-
return code === "ENODATA" || code === "ENOTFOUND" ? [] : null;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* The "Enter switches paths" race from 0078's flow. The default reads one
|
|
524
|
-
* cooked-mode line from stdin; `stop` removes the listener so a losing race
|
|
525
|
-
* cannot eat the first keystroke of a later prompt.
|
|
526
|
-
*/
|
|
527
|
-
function createEnterRace(waitForEnter) {
|
|
528
|
-
if (waitForEnter) return {
|
|
529
|
-
promise: waitForEnter().then(() => "enter"),
|
|
530
|
-
stop: () => {}
|
|
531
|
-
};
|
|
532
|
-
const stdin = process.stdin;
|
|
533
|
-
let onData;
|
|
534
|
-
return {
|
|
535
|
-
promise: new Promise((resolve) => {
|
|
536
|
-
if (!stdin.isTTY) return;
|
|
537
|
-
onData = () => resolve("enter");
|
|
538
|
-
stdin.on("data", onData);
|
|
539
|
-
}),
|
|
540
|
-
stop: () => {
|
|
541
|
-
if (onData) stdin.removeListener("data", onData);
|
|
542
|
-
}
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
async function defaultPasteToken() {
|
|
546
|
-
const result = await password({
|
|
547
|
-
message: "Paste the Cloudflare API token:",
|
|
548
|
-
mask: "*"
|
|
549
|
-
});
|
|
550
|
-
if (isCancel(result) || typeof result === "symbol") return null;
|
|
551
|
-
return result;
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* Lost-answer reconcile bounds for `domain add`: attempt-count bounded (not
|
|
555
|
-
* wall clock) so the worst case is finite and tests are deterministic. The
|
|
556
|
-
* API's pre-insert phase is DB-only but takes tens of ms to seconds of
|
|
557
|
-
* Hyperdrive RTTs, and a lost answer does not stop the API worker — a single
|
|
558
|
-
* immediate status read can 404 while the POST is still committing.
|
|
559
|
-
*/
|
|
560
|
-
const ADD_RECONCILE_ATTEMPTS = 15;
|
|
561
|
-
const ADD_RECONCILE_INTERVAL_MS = 1e3;
|
|
562
|
-
/**
|
|
563
|
-
* `void email domain add <domain>` — exported for the unit test; the command
|
|
564
|
-
* entry resolves the client and project first.
|
|
565
|
-
*
|
|
566
|
-
* The flow (0078, "The flow"): render the MX decision → the OAuth grant →
|
|
567
|
-
* the template-link + clipboard fallback (Enter switches to the masked paste
|
|
568
|
-
* at any point) → POST the credential once → render the resulting row. A
|
|
569
|
-
* failed add leaves the platform row 'failed' and re-running add replaces
|
|
570
|
-
* the credential — the api's replace path, not anything here.
|
|
571
|
-
*/
|
|
572
|
-
async function emailDomainAdd(client, projectId, rawDomain, subdomain, deps = {}) {
|
|
573
|
-
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
574
|
-
const fail = (message) => {
|
|
575
|
-
log.error(message);
|
|
576
|
-
return exit(1);
|
|
577
|
-
};
|
|
578
|
-
const domain = normalizeEmailDomainInput(rawDomain);
|
|
579
|
-
if (!isValidEmailDomainInput(domain)) return fail(`email: invalid domain '${rawDomain}'.`);
|
|
580
|
-
let target;
|
|
581
|
-
try {
|
|
582
|
-
target = resolveEmailDomainTarget(domain, subdomain);
|
|
583
|
-
} catch (err) {
|
|
584
|
-
return fail(err instanceof Error ? err.message : String(err));
|
|
585
|
-
}
|
|
586
|
-
const dns = deps.dns ?? await createNodeEmailDns();
|
|
587
|
-
if (target.forced) {
|
|
588
|
-
const mx = await lookupMxForRender(dns, target.emailDomain);
|
|
589
|
-
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.`);
|
|
590
|
-
} else {
|
|
591
|
-
const mx = await lookupMxForRender(dns, domain);
|
|
592
|
-
if (mx === null) log.info(`MX could not read ${domain}'s MX from here — the platform decides over public DNS.`);
|
|
593
|
-
else if (mx.length > 0) {
|
|
594
|
-
target = {
|
|
595
|
-
emailDomain: `mail.${domain}`,
|
|
596
|
-
forced: false
|
|
597
|
-
};
|
|
598
|
-
log.info(`MX ${domain} carries MX for ${mx[0]} → apex is off limits\n using ${target.emailDomain} [change with --subdomain]`);
|
|
599
|
-
const subMx = await lookupMxForRender(dns, target.emailDomain);
|
|
600
|
-
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.`);
|
|
601
|
-
} else log.info(`MX ${domain} has no MX records → using the apex`);
|
|
602
|
-
}
|
|
603
|
-
if (!(deps.isInteractive ?? !isWranglerNonInteractiveOrCI())) return fail("email: `void email domain add` needs an interactive terminal (the grant opens a browser; the fallback reads the clipboard or a paste). Run it locally.");
|
|
604
|
-
let credential;
|
|
605
|
-
const runGrant = deps.runGrant ?? ((options) => runCfOauthGrant(options));
|
|
606
|
-
const grantSpinner = createSpinner();
|
|
607
|
-
grantSpinner.start("Opening Cloudflare's consent page…");
|
|
608
|
-
const controller = new AbortController();
|
|
609
|
-
const enterDuringGrant = createEnterRace(deps.waitForEnter);
|
|
610
|
-
const grantOutcome = await Promise.race([runGrant({
|
|
611
|
-
signal: controller.signal,
|
|
612
|
-
onUrl: (url) => grantSpinner.message(`Waiting for the Cloudflare grant — click Allow. If the browser didn't open, visit:\n${import_picocolors.default.cyan(url)}\n(Enter switches to the API-token path.)`)
|
|
613
|
-
}).then((grant) => ({
|
|
614
|
-
type: "grant",
|
|
615
|
-
grant
|
|
616
|
-
}), (error) => ({
|
|
617
|
-
type: "error",
|
|
618
|
-
error: error instanceof Error ? error : new Error(String(error))
|
|
619
|
-
})), enterDuringGrant.promise.then(() => ({ type: "enter" }))]);
|
|
620
|
-
enterDuringGrant.stop();
|
|
621
|
-
if (grantOutcome.type === "grant") {
|
|
622
|
-
grantSpinner.stop("Grant received.");
|
|
623
|
-
credential = {
|
|
624
|
-
kind: "oauth_grant",
|
|
625
|
-
refreshToken: grantOutcome.grant.refreshToken
|
|
626
|
-
};
|
|
627
|
-
} else {
|
|
628
|
-
controller.abort();
|
|
629
|
-
grantSpinner.stop(grantOutcome.type === "enter" ? "Switched to the API-token path." : "The consent flow did not complete — using the API-token path.");
|
|
630
|
-
if (grantOutcome.type === "error") log.warn(grantOutcome.error.message);
|
|
631
|
-
}
|
|
632
|
-
if (credential === void 0) {
|
|
633
|
-
const url = buildCfApiTokenTemplateUrl(target.emailDomain);
|
|
634
|
-
const readClip = deps.readClipboard ?? readClipboard;
|
|
635
|
-
const clearClip = deps.clearClipboard ?? clearClipboard;
|
|
636
|
-
const verify = deps.verifyToken ?? verifyCfApiToken;
|
|
637
|
-
const openUrl = deps.openUrl ?? ((u) => {
|
|
638
|
-
import("node:child_process").then(({ execFile }) => openBrowser(u, execFile));
|
|
639
|
-
});
|
|
640
|
-
const snapshot = readClip();
|
|
641
|
-
if (snapshot === null) log.info("No clipboard tool found here — paste the token instead.");
|
|
642
|
-
openUrl(url);
|
|
643
|
-
let outcome;
|
|
644
|
-
let watchSpinner;
|
|
645
|
-
if (snapshot === null) outcome = { type: "timeout" };
|
|
646
|
-
else {
|
|
647
|
-
watchSpinner = createSpinner();
|
|
648
|
-
watchSpinner.start([
|
|
649
|
-
"Create the token in three clicks: Continue to summary → Create Token → Copy.",
|
|
650
|
-
"Watching the clipboard for it — nothing is read until you copy, and it is cleared afterwards.",
|
|
651
|
-
"(Enter pastes it yourself instead.)",
|
|
652
|
-
`If the browser didn't open, visit:\n${import_picocolors.default.cyan(url)}`
|
|
653
|
-
].join("\n"));
|
|
654
|
-
const spinner = watchSpinner;
|
|
655
|
-
const deadline = Date.now() + (deps.watchTimeoutMs ?? 6e5);
|
|
656
|
-
const interval = deps.watchIntervalMs ?? 1e3;
|
|
657
|
-
const snapshotTrimmed = snapshot.trim();
|
|
658
|
-
const enterDuringWatch = createEnterRace(deps.waitForEnter);
|
|
659
|
-
let watchSettled = false;
|
|
660
|
-
let watchTimer;
|
|
661
|
-
outcome = await Promise.race([(async () => {
|
|
662
|
-
while (!watchSettled && Date.now() < deadline) {
|
|
663
|
-
await new Promise((resolve) => {
|
|
664
|
-
watchTimer = setTimeout(resolve, interval);
|
|
665
|
-
});
|
|
666
|
-
if (watchSettled) break;
|
|
667
|
-
const value = readClip()?.trim();
|
|
668
|
-
if (!value || value === snapshotTrimmed) continue;
|
|
669
|
-
const shape = classifyCfApiTokenShape(value);
|
|
670
|
-
if (shape === "refused" || shape === "unknown") continue;
|
|
671
|
-
const verdict = await verify(value);
|
|
672
|
-
if (verdict === "inactive") {
|
|
673
|
-
spinner.message("That is not an active Cloudflare token — still waiting.");
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
if (verdict === "unknown") log.info("Could not reach Cloudflare to check the token — the platform will verify it.");
|
|
677
|
-
return {
|
|
678
|
-
type: "token",
|
|
679
|
-
token: value
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
return { type: "timeout" };
|
|
683
|
-
})(), enterDuringWatch.promise.then(() => ({ type: "enter" }))]);
|
|
684
|
-
watchSettled = true;
|
|
685
|
-
enterDuringWatch.stop();
|
|
686
|
-
watchTimer?.unref();
|
|
687
|
-
}
|
|
688
|
-
if (outcome.type === "token") {
|
|
689
|
-
clearClip();
|
|
690
|
-
watchSpinner?.stop("Token received — the clipboard copy was cleared.");
|
|
691
|
-
log.info("A clipboard manager may keep its own history — clear it there if you run one.");
|
|
692
|
-
credential = {
|
|
693
|
-
kind: "api_token",
|
|
694
|
-
apiToken: outcome.token
|
|
695
|
-
};
|
|
696
|
-
} else {
|
|
697
|
-
watchSpinner?.stop(outcome.type === "enter" ? "Pasting instead." : "No token arrived — paste it instead.");
|
|
698
|
-
const pasted = await (deps.pasteToken ?? defaultPasteToken)();
|
|
699
|
-
if (pasted === null) {
|
|
700
|
-
log.info("Cancelled — nothing was stored anywhere.");
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
const token = pasted.trim();
|
|
704
|
-
const shape = classifyCfApiTokenShape(token);
|
|
705
|
-
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.");
|
|
706
|
-
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.");
|
|
707
|
-
const verdict = await verify(token);
|
|
708
|
-
if (verdict === "inactive") return fail("email: that is not an active Cloudflare token.");
|
|
709
|
-
if (verdict === "unknown") log.info("Could not reach Cloudflare to check the token — the platform will verify it.");
|
|
710
|
-
credential = {
|
|
711
|
-
kind: "api_token",
|
|
712
|
-
apiToken: token
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
const attemptId = crypto.randomUUID();
|
|
717
|
-
let row;
|
|
718
|
-
try {
|
|
719
|
-
row = await client.addEmailDomain(projectId, {
|
|
720
|
-
domain: target.emailDomain,
|
|
721
|
-
...credential.kind === "oauth_grant" ? { refreshToken: credential.refreshToken } : { apiToken: credential.apiToken },
|
|
722
|
-
attemptId
|
|
723
|
-
});
|
|
724
|
-
} catch (err) {
|
|
725
|
-
if (err instanceof PlatformApiError && (err.status === 400 || err.status === 404 || err.status === 409)) throw err;
|
|
726
|
-
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
727
|
-
let lastRead;
|
|
728
|
-
for (let attempt = 0; attempt < ADD_RECONCILE_ATTEMPTS; attempt++) {
|
|
729
|
-
if (attempt > 0) await sleep(ADD_RECONCILE_INTERVAL_MS);
|
|
730
|
-
let existing;
|
|
731
|
-
try {
|
|
732
|
-
existing = await client.getEmailDomain(projectId, target.emailDomain);
|
|
733
|
-
} catch (statusErr) {
|
|
734
|
-
if (statusErr instanceof PlatformApiError && statusErr.status === 404) lastRead = "missing";
|
|
735
|
-
continue;
|
|
736
|
-
}
|
|
737
|
-
if ("attemptId" in existing) {
|
|
738
|
-
if (existing.attemptId === attemptId) {
|
|
739
|
-
log.warn("The add's answer was lost (network or platform error), but the platform has the row.");
|
|
740
|
-
printEmailDomainOutcome(existing);
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
lastRead = "foreign";
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
log.warn("The add's answer was lost (network or platform error), but the platform has the row.");
|
|
747
|
-
printEmailDomainOutcome(existing);
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
if (lastRead === "foreign") {
|
|
751
|
-
if (credential.kind === "oauth_grant") return fail(await revokeCfOauthGrant(credential.refreshToken) ? `email: the add did not complete, and ${target.emailDomain} kept the row's existing credential; the OAuth grant minted for this attempt was revoked — re-run to retry the replace.` : `email: the add did not complete, and ${target.emailDomain} kept the row's existing credential; the OAuth grant minted for this attempt could not be revoked — revoke it in the Cloudflare dashboard, then re-run to retry the replace.`);
|
|
752
|
-
return fail(`email: the add did not complete, and ${target.emailDomain} kept the row's existing credential. The token was not stored anywhere; delete it in the Cloudflare dashboard if you are not retrying.`);
|
|
753
|
-
}
|
|
754
|
-
if (lastRead === "missing") {
|
|
755
|
-
if (credential.kind === "oauth_grant") return fail(await revokeCfOauthGrant(credential.refreshToken) ? `email: the add did not complete — ${target.emailDomain} was not registered, and the OAuth grant minted for this attempt was revoked.` : `email: the add did not complete — ${target.emailDomain} was not registered; the OAuth grant minted for this attempt could not be revoked — revoke it in the Cloudflare dashboard before retrying.`);
|
|
756
|
-
return fail(`email: the add did not complete — ${target.emailDomain} was not registered. The token was not stored anywhere; delete it in the Cloudflare dashboard if you are not retrying.`);
|
|
757
|
-
}
|
|
758
|
-
return fail(`email: the add did not complete, and the follow-up check failed too — the add's state is unknown, so nothing was revoked. A grant minted for this attempt may still be live: once connectivity is back, run \`void email domain status ${target.emailDomain}\`; if it shows no row, revoke the grant in the Cloudflare dashboard before retrying.`);
|
|
759
|
-
}
|
|
760
|
-
log.success(credential.kind === "oauth_grant" ? "Grant stored on the platform, encrypted for this project — nothing is kept on this machine." : "Token stored on the platform, encrypted for this project — nothing is kept on this machine.");
|
|
761
|
-
printEmailDomainOutcome(row);
|
|
762
|
-
}
|
|
763
|
-
function emailDomainStatusText(status) {
|
|
764
|
-
if (status === "active") return import_picocolors.default.green("active");
|
|
765
|
-
if (status === "pending") return import_picocolors.default.yellow("pending");
|
|
766
|
-
return import_picocolors.default.red(status === "failed" ? "failed" : status.replace("_", " "));
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* The dashboard path for the one remaining human step. The row does not
|
|
770
|
-
* carry the zone name, so a subdomain row strips its first label — the zone
|
|
771
|
-
* the walk almost always lands on (`mail.acme.com` → `acme.com`).
|
|
772
|
-
*/
|
|
773
|
-
function routingSubdomainInstruction(domain, mode) {
|
|
774
|
-
return `add ${domain} in the Cloudflare dashboard — Email Routing → ${mode === "subdomain" ? domain.split(".").slice(1).join(".") : domain} → Settings → Subdomains (no API exists for it)`;
|
|
775
|
-
}
|
|
776
|
-
/**
|
|
777
|
-
* The shared result render for add and sync: the row as a block, then the
|
|
778
|
-
* one guidance line the status calls for. `error` is printed verbatim — the
|
|
779
|
-
* api guarantees it names a step or a CF error, never credential material.
|
|
780
|
-
*/
|
|
781
|
-
function printEmailDomainOutcome(row) {
|
|
782
|
-
if (row.status === "failed") {
|
|
783
|
-
const step = row.setup_state?.failed_step;
|
|
784
|
-
log.error(`email: ${row.domain} failed${step ? ` at ${step.replaceAll("_", " ")}` : ""}${row.error ? ` — ${row.error}` : ""}`);
|
|
785
|
-
log.info(`Fix the step above, then re-run \`void email domain add ${row.domain}\` — adding again replaces the credential and retries.`);
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
const lines = [
|
|
789
|
-
`Domain: ${row.domain}`,
|
|
790
|
-
`Status: ${emailDomainStatusText(row.status)}`,
|
|
791
|
-
`Mode: ${row.mode ?? "—"}`,
|
|
792
|
-
`Credential: ${row.credential_kind === "oauth_grant" ? "OAuth grant" : "API token"}`
|
|
793
|
-
];
|
|
794
|
-
if (row.relay_worker_name) lines.push(`Relay: ${row.relay_worker_name}${row.relay_version !== null ? ` v${row.relay_version}` : ""}`);
|
|
795
|
-
if (row.token_expires_on) lines.push(`Token TTL: expires ${row.token_expires_on}`);
|
|
796
|
-
note(lines.join("\n"), "Email Domain");
|
|
797
|
-
const remaining = row.setup_state?.remaining_steps ?? [];
|
|
798
|
-
if (row.status === "pending" && remaining.includes("routing_subdomain")) {
|
|
799
|
-
log.warn(`One step only you can do: ${routingSubdomainInstruction(row.domain, row.mode)}.`);
|
|
800
|
-
log.info(`Then run \`void email domain status ${row.domain}\` to poll.`);
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
if (row.status === "pending") {
|
|
804
|
-
log.info(`Run \`void email domain status ${row.domain}\` to poll.`);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
if (row.status === "token_revoked" || row.status === "token_expired") {
|
|
808
|
-
log.warn(`The stored credential was ${row.status === "token_revoked" ? "revoked at Cloudflare" : "left to expire"} — re-run \`void email domain add ${row.domain}\` with a fresh grant or token. Rules and the relay stay.`);
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
if (remaining.includes("sending_onboarding")) log.info(`Inbound is live. Sending to arbitrary recipients needs Workers Paid — until then sendEmail() works to verified destinations only. After upgrading, run \`void email domain sync ${row.domain}\` to finish onboarding.`);
|
|
812
|
-
log.success(`${row.domain} is live — send with \`sendEmail({ from: 'you@${row.domain}', … })\`; inbound mail to any address on ${row.domain} reaches your email/ handlers.`);
|
|
813
|
-
}
|
|
814
|
-
/**
|
|
815
|
-
* `void email domain status <domain>` — exported for the unit test. Re-probes
|
|
816
|
-
* the credential and the relay on every call (the api does it), so this is
|
|
817
|
-
* also the drift report: it never fights the customer's edits, it names them.
|
|
818
|
-
*/
|
|
819
|
-
async function emailDomainStatus(client, projectId, rawDomain) {
|
|
820
|
-
const domain = normalizeEmailDomainInput(rawDomain);
|
|
821
|
-
const row = await client.getEmailDomain(projectId, domain);
|
|
822
|
-
const lines = [
|
|
823
|
-
`Domain: ${row.domain}`,
|
|
824
|
-
`Status: ${emailDomainStatusText(row.status)}`,
|
|
825
|
-
`Mode: ${row.mode ?? "—"}`
|
|
826
|
-
];
|
|
827
|
-
const credential = row.credential_status === "probe_failed" ? `${row.credential_kind === "oauth_grant" ? "OAuth grant" : "API token"} · probe failed (stored view shown)` : `${row.credential_kind === "oauth_grant" ? "OAuth grant" : "API token"} · ${row.credential_status}`;
|
|
828
|
-
lines.push(`Credential: ${credential}`);
|
|
829
|
-
if (row.token_id) lines.push(`Token: id ${row.token_id}${row.token_expires_on ? ` · expires ${row.token_expires_on}` : ""}`);
|
|
830
|
-
if (row.relay_worker_name) {
|
|
831
|
-
let relay = `Relay: ${row.relay_worker_name}${row.relay_version !== null ? ` v${row.relay_version}` : ""}`;
|
|
832
|
-
if (row.relay_missing === true) relay += " · missing from the account";
|
|
833
|
-
else if (row.relay_drift) relay += ` · drifted (current is v${row.current_relay_version})`;
|
|
834
|
-
lines.push(relay);
|
|
835
|
-
}
|
|
836
|
-
if (row.setup_state?.failed_step) lines.push(`Failed step: ${row.setup_state.failed_step.replaceAll("_", " ")}`);
|
|
837
|
-
lines.push(`Catch-all: ${row.catch_all}`);
|
|
838
|
-
note(lines.join("\n"), "Email Domain");
|
|
839
|
-
if (row.error) log.error(`email: ${row.error}`);
|
|
840
|
-
const remaining = row.setup_state?.remaining_steps ?? [];
|
|
841
|
-
if (remaining.includes("routing_subdomain")) log.warn(`One step only you can do: ${routingSubdomainInstruction(row.domain, row.mode)}.`);
|
|
842
|
-
if (remaining.includes("sending_onboarding")) log.info(`Sending to arbitrary recipients needs Workers Paid — inbound works; sendEmail() to verified destinations only. After upgrading, run \`void email domain sync ${row.domain}\`.`);
|
|
843
|
-
if (row.relay_missing === true || row.relay_drift) log.info(`Run \`void email domain sync ${row.domain}\` to redeploy the relay in place.`);
|
|
844
|
-
if (row.catch_all_drift) log.info(`Catch-all drifted — run \`void email domain sync ${row.domain}\` to re-point it.`);
|
|
845
|
-
if (row.status === "token_revoked" || row.status === "token_expired") log.warn(`Re-run \`void email domain add ${row.domain}\` with a fresh grant or token — adding again replaces the credential, rules stay.`);
|
|
846
|
-
}
|
|
847
|
-
/** `void email domain list` — exported for the unit test. */
|
|
848
|
-
async function emailDomainList(client, projectId) {
|
|
849
|
-
const rows = await client.listEmailDomains(projectId);
|
|
850
|
-
if (rows.length === 0) {
|
|
851
|
-
log.info("No email domains. Run `void email domain add <domain>` to send and receive at your own domain on a Cloudflare zone.");
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
const width = Math.max(...rows.map((row) => row.domain.length));
|
|
855
|
-
const lines = rows.map((row) => ` ${row.domain.padEnd(width)} ${emailDomainStatusText(row.status)} ${row.mode ?? "—"}`);
|
|
856
|
-
note(lines.join("\n"), "Email Domains");
|
|
857
|
-
}
|
|
858
|
-
/** `void email domain sync <domain>` — exported for the unit test. */
|
|
859
|
-
async function emailDomainSync(client, projectId, rawDomain) {
|
|
860
|
-
const domain = normalizeEmailDomainInput(rawDomain);
|
|
861
|
-
const row = await client.syncEmailDomain(projectId, domain);
|
|
862
|
-
if (row.status !== "failed") {
|
|
863
|
-
log.success(`Relay redeployed${row.relay_version !== null ? ` at v${row.relay_version}` : ""} with a rotated secret.`);
|
|
864
|
-
if (row.catch_all === "foreign_conflict") log.warn("Catch-all: the zone's catch-all points at a different worker and was NOT overwritten — change it in the zone's Email Routing settings if inbound mail should reach this relay.");
|
|
865
|
-
else if (row.catch_all !== void 0) log.info(`Catch-all: ${row.catch_all.replaceAll("_", " ")}`);
|
|
866
|
-
}
|
|
867
|
-
printEmailDomainOutcome(row);
|
|
868
|
-
}
|
|
869
|
-
/** `void email domain remove <domain>` — exported for the unit test. */
|
|
870
|
-
async function emailDomainRemove(client, projectId, rawDomain, options = {}) {
|
|
871
|
-
const domain = normalizeEmailDomainInput(rawDomain);
|
|
872
|
-
const result = await client.removeEmailDomain(projectId, domain, options.force);
|
|
873
|
-
if (!result.removed) {
|
|
874
|
-
const steps = result.failed_steps?.length ? result.failed_steps.join(", ") : "unknown";
|
|
875
|
-
log.error(`email: could not remove ${domain} — failed Cloudflare-side steps: ${steps}.`);
|
|
876
|
-
log.info(`The registration was kept — retry \`void email domain remove ${domain}\` once the Cloudflare-side problem is fixed, or re-run with --force to drop the row anyway.`);
|
|
877
|
-
(options.exit ?? ((code) => process.exit(code)))(1);
|
|
878
|
-
}
|
|
879
|
-
log.success(`Removed ${domain} — the platform row, the stored credential and the relay secret are gone.`);
|
|
880
|
-
const failed = result.failed_steps ?? [];
|
|
881
|
-
if (failed.length > 0 && (result.forced === true || failed.some((step) => step !== "revoke_grant"))) log.warn(`Cloudflare-side cleanup failed for: ${failed.join(", ")} — delete the relay worker and reset the catch-all in the zone's Email Routing settings yourself.`);
|
|
882
|
-
else if (failed.length > 0) log.info("Note: the Cloudflare grant could not be revoked (already invalid or Cloudflare unreachable) — no mail impact.");
|
|
883
|
-
}
|
|
884
|
-
async function runEmailDomainCommand(client, projectId, args) {
|
|
885
|
-
switch (args.domainVerb) {
|
|
886
|
-
case "add": return emailDomainAdd(client, projectId, args.domain, args.subdomain);
|
|
887
|
-
case "status": return emailDomainStatus(client, projectId, args.domain);
|
|
888
|
-
case "list": return emailDomainList(client, projectId);
|
|
889
|
-
case "sync": return emailDomainSync(client, projectId, args.domain);
|
|
890
|
-
case "remove": return emailDomainRemove(client, projectId, args.domain, { force: args.force });
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* `void email setup --platform cloudflare` and `void email status --platform cloudflare`.
|
|
895
|
-
*
|
|
896
|
-
* `setup` is the deploy's step 3b-ii on its own: preflight → checklist → the one Enter →
|
|
897
|
-
* apply → write the root wrangler.jsonc. It exists for CI: a non-interactive deploy that
|
|
898
|
-
* finds email not ready prints the checklist and says to run this once, commit
|
|
899
|
-
* wrangler.jsonc, and redeploy. Rules and the catch-all stay wrangler's job — the next
|
|
900
|
-
* `void deploy --platform cloudflare` applies the plan from the written `addresses`. It
|
|
901
|
-
* is also the ONLY place a sending onboarding the committed binding remembers as refused
|
|
902
|
-
* (Workers Free) is retried — on its own prompt, after a Workers Paid upgrade; the deploy
|
|
903
|
-
* reads that row as settled and never retries it.
|
|
904
|
-
*
|
|
905
|
-
* `status` is the preflight alone, printed, exit 1 when not ready.
|
|
906
|
-
*/
|
|
907
|
-
async function runEmailCloudflareCommand(root, args, deps = {}) {
|
|
908
|
-
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
909
|
-
const print = deps.print ?? ((block) => log.message(block));
|
|
910
|
-
const fail = (message) => {
|
|
911
|
-
log.error(message);
|
|
912
|
-
return exit(1);
|
|
913
|
-
};
|
|
914
|
-
const from = readConfig(root).email?.from;
|
|
915
|
-
if (!from) return fail("email: no sender configured. Add \"email\": { \"from\": \"you@mail.acme.com\" } to void.json — its host is the domain email is set up on.");
|
|
916
|
-
const paths = readProjectPaths(root);
|
|
917
|
-
const handlers = scanEmailHandlersSync(paths.sourceRoot);
|
|
918
|
-
const configPath = discoverRootWranglerConfig(root);
|
|
919
|
-
const rootConfig = parseRootWranglerConfig(configPath);
|
|
920
|
-
await loadProvisionModule();
|
|
921
|
-
let apiBaseUrl;
|
|
922
|
-
try {
|
|
923
|
-
apiBaseUrl = resolveDeployTransport(root, rootConfig);
|
|
924
|
-
} catch (err) {
|
|
925
|
-
return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
|
|
926
|
-
}
|
|
927
|
-
const accountId = readWranglerTopLevelString(rootConfig, "account_id") ?? (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || void 0);
|
|
928
|
-
if (!accountId) return fail([
|
|
929
|
-
"email: no Cloudflare account is pinned.",
|
|
930
|
-
"Set `account_id` in your root wrangler.jsonc, or export CLOUDFLARE_ACCOUNT_ID —",
|
|
931
|
-
"the same pin `void deploy --platform cloudflare` needs."
|
|
932
|
-
].join("\n"));
|
|
933
|
-
const workerName = readWranglerTopLevelString(rootConfig, "name") ?? deriveWorkerName(root);
|
|
934
|
-
const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl);
|
|
935
|
-
const getSessionBearer = deps.getSessionBearer ?? (() => runWranglerAuthToken(runWrangler, configPath));
|
|
936
|
-
const sendEmail = rootConfig.send_email;
|
|
937
|
-
const inputs = {
|
|
938
|
-
handlers,
|
|
939
|
-
from,
|
|
940
|
-
workerName,
|
|
941
|
-
accountId,
|
|
942
|
-
existingSendEmail: Array.isArray(sendEmail) ? sendEmail : null,
|
|
943
|
-
existingAddresses: rootConfig.addresses,
|
|
944
|
-
existingVars: rootConfig.vars && typeof rootConfig.vars === "object" && !Array.isArray(rootConfig.vars) ? rootConfig.vars : null,
|
|
945
|
-
configConflicts: findEmailConfigConflicts(rootConfig),
|
|
946
|
-
wranglerArgs: [
|
|
947
|
-
"-c",
|
|
948
|
-
configPath,
|
|
949
|
-
"--env="
|
|
950
|
-
],
|
|
951
|
-
apiBaseUrl,
|
|
952
|
-
runWrangler,
|
|
953
|
-
getSessionBearer,
|
|
954
|
-
fetchImpl: deps.fetchImpl ?? fetch,
|
|
955
|
-
dns: deps.dns ?? await createNodeEmailDns()
|
|
956
|
-
};
|
|
957
|
-
let pre;
|
|
958
|
-
try {
|
|
959
|
-
pre = await preflightEmail(inputs);
|
|
960
|
-
} catch (err) {
|
|
961
|
-
return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
|
|
962
|
-
}
|
|
963
|
-
print(renderEmailChecklist(pre));
|
|
964
|
-
if (args.subcommand === "status") {
|
|
965
|
-
print(renderAddressMap({
|
|
966
|
-
from,
|
|
967
|
-
routes: pre.derived.routes,
|
|
968
|
-
addresses: pre.rules.mine,
|
|
969
|
-
sendingReady: pre.sending.state === "ready",
|
|
970
|
-
pendingLabel: "(no rule yet)"
|
|
971
|
-
}));
|
|
972
|
-
if (pre.ready) {
|
|
973
|
-
log.success(`Email is set up on ${pre.domain}.`);
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
log.warn(`Email is not set up on ${pre.domain} yet — run \`void email setup --platform cloudflare\`.`);
|
|
977
|
-
return exit(1);
|
|
978
|
-
}
|
|
979
|
-
if (pre.blockers.some((b) => b.scope === "all")) return fail("email: nothing was changed — fix the ✘ rows above and rerun.");
|
|
980
|
-
if (!pre.ready || pre.sendingRetryable) {
|
|
981
|
-
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.");
|
|
982
|
-
if (!await (deps.confirm ?? (async (message) => {
|
|
983
|
-
const answer = await confirm({
|
|
984
|
-
message,
|
|
985
|
-
initialValue: true
|
|
986
|
-
});
|
|
987
|
-
return !isCancel(answer) && answer === true;
|
|
988
|
-
}))(pre.ready ? `Onboard ${pre.domain} for Email Sending? Inbound already works; onboarding needs Workers Paid.` : `Set up email on ${pre.domain}?`)) {
|
|
989
|
-
log.info("Nothing changed.");
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
const out = await applyEmailSetup(pre, inputs);
|
|
994
|
-
if (out.lines.length > 0) print(out.lines.join("\n"));
|
|
995
|
-
try {
|
|
996
|
-
writeProvisionedWranglerConfig(root, out.patch, { configPath });
|
|
997
|
-
} catch (err) {
|
|
998
|
-
return fail(`email: could not update ${configPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
999
|
-
}
|
|
1000
|
-
print(renderAddressMap({
|
|
1001
|
-
from,
|
|
1002
|
-
routes: pre.derived.routes,
|
|
1003
|
-
addresses: out.patch.addresses ?? [],
|
|
1004
|
-
sendingReady: out.sendingReady,
|
|
1005
|
-
pendingLabel: "(no rule yet)"
|
|
1006
|
-
}));
|
|
1007
|
-
if (!out.inboundReady) {
|
|
1008
|
-
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";
|
|
1009
|
-
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\`.`);
|
|
1010
|
-
return exit(1);
|
|
1011
|
-
}
|
|
1012
|
-
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`.");
|
|
1013
|
-
log.success(`Email set up on ${pre.domain}.`);
|
|
1014
|
-
}
|
|
1015
|
-
//#endregion
|
|
1016
|
-
export { runEmailCommand };
|