@getvitops/utils 8.1.0 → 8.2.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +264 -8
- package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
- package/dist/adc-BoEoogyo.d.mts.map +1 -0
- package/dist/ads/index.d.mts +50 -17
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +75 -7
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- package/dist/color/index.d.mts +34 -35
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +3 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +288 -0
- package/dist/crm/index.d.mts.map +1 -0
- package/dist/crm/index.mjs +731 -0
- package/dist/crm/index.mjs.map +1 -0
- package/dist/email/index.d.mts +216 -0
- package/dist/email/index.d.mts.map +1 -0
- package/dist/email/index.mjs +331 -0
- package/dist/email/index.mjs.map +1 -0
- package/dist/favicon.d.mts +8 -9
- package/dist/favicon.d.mts.map +1 -1
- package/dist/git/index.d.mts +65 -0
- package/dist/git/index.d.mts.map +1 -0
- package/dist/git/index.mjs +107 -0
- package/dist/git/index.mjs.map +1 -0
- package/dist/hosting/index.d.mts +29 -29
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +7 -4
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/identity-BoRddTx3.mjs +35 -0
- package/dist/identity-BoRddTx3.mjs.map +1 -0
- package/dist/index.d.mts +24 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +23 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/media/index.d.mts +24 -24
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +26 -15
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +50 -20
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +10 -141
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +1 -144
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-BLKikoOG.mjs +150 -0
- package/dist/plan-BLKikoOG.mjs.map +1 -0
- package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
- package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
- package/dist/reviews/index.d.mts +12 -13
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/schema/index.d.mts +7 -7
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/security/index.d.mts +2 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
- package/dist/token-DmmoQ7um.d.mts.map +1 -0
- package/dist/tracking/index.d.mts +43 -17
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +2 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
- package/dist/types-7ELci8RQ.d.mts.map +1 -0
- package/package.json +14 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { t as table } from "../plan-format-A18SvJ7C.mjs";
|
|
2
|
+
import { t as backoffSchedule } from "../plan-BLKikoOG.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/email/plan.ts
|
|
5
|
+
/** Decide one domain's step from its zone's observed sending names. */
|
|
6
|
+
function planDomain(domain, provider, state) {
|
|
7
|
+
if (provider !== "cloudflare") return {
|
|
8
|
+
action: "skip",
|
|
9
|
+
detail: `no onboarding check implemented for "${provider}"`
|
|
10
|
+
};
|
|
11
|
+
if (!state) return {
|
|
12
|
+
action: "blocked",
|
|
13
|
+
detail: `no observed Cloudflare state for "${domain}" — is it in this account? (needs CLOUDFLARE_API_TOKEN with Zone:Read + an Email Sending scope)`
|
|
14
|
+
};
|
|
15
|
+
const exact = state.subdomains.find((s) => s.name === domain);
|
|
16
|
+
if (exact) return exact.enabled ? {
|
|
17
|
+
action: "skip",
|
|
18
|
+
detail: `already onboarded and enabled`
|
|
19
|
+
} : {
|
|
20
|
+
action: "create",
|
|
21
|
+
detail: `onboarded but disabled — \`wrangler email sending enable ${domain}\` (or re-enable in the dashboard)`
|
|
22
|
+
};
|
|
23
|
+
const sibling = state.subdomains.find((s) => s.enabled);
|
|
24
|
+
if (sibling) return {
|
|
25
|
+
action: "blocked",
|
|
26
|
+
detail: `"${domain}" is not onboarded, but "${sibling.name}" already is in the same zone — send from "@${sibling.name}" instead (isolates transactional mail's DMARC/reputation from the domain's regular mail), or run \`wrangler email sending enable ${domain}\` to onboard the apex too (this rewrites _dmarc.${domain} — check what's there first)`
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
action: "create",
|
|
30
|
+
detail: `not onboarded — \`wrangler email sending enable ${domain}\``
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Plan every candidate domain. One entry per distinct domain, sources merged. */
|
|
34
|
+
function planSendingDomains(config, states) {
|
|
35
|
+
const byDomain = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const c of config.candidates) {
|
|
37
|
+
const sources = byDomain.get(c.domain) ?? [];
|
|
38
|
+
if (!sources.includes(c.source)) sources.push(c.source);
|
|
39
|
+
byDomain.set(c.domain, sources);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
domains: [...byDomain.entries()].map(([domain, sources]) => {
|
|
43
|
+
const onboard = planDomain(domain, config.provider, states.get(domain));
|
|
44
|
+
return {
|
|
45
|
+
domain,
|
|
46
|
+
sources,
|
|
47
|
+
onboard,
|
|
48
|
+
reminders: onboard.action === "create" && config.provider === "cloudflare" ? ["DNS takes minutes to propagate before mail sent from it will authenticate."] : []
|
|
49
|
+
};
|
|
50
|
+
}),
|
|
51
|
+
notes: []
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Any step that isn't a skip — i.e. drift, worth reporting. */
|
|
55
|
+
function hasDrift(p) {
|
|
56
|
+
return p.domains.some((d) => d.onboard.action !== "skip");
|
|
57
|
+
}
|
|
58
|
+
/** Every domain still blocked, for a summary that names what's outstanding. */
|
|
59
|
+
function blockedDomains(p) {
|
|
60
|
+
return p.domains.filter((d) => d.onboard.action === "blocked");
|
|
61
|
+
}
|
|
62
|
+
/** Render a plan as the block `--dry`/`--check`/`email check` print. */
|
|
63
|
+
function formatPlan(p) {
|
|
64
|
+
const glyph = (s) => s.action === "skip" ? "·" : s.action === "blocked" ? "?" : "+";
|
|
65
|
+
const lines = [];
|
|
66
|
+
for (const d of p.domains) {
|
|
67
|
+
lines.push(`${d.domain} (${d.sources.join(", ")})`);
|
|
68
|
+
lines.push(` ${glyph(d.onboard)} ${d.onboard.detail}`);
|
|
69
|
+
for (const r of d.reminders) lines.push(` ! ${r}`);
|
|
70
|
+
}
|
|
71
|
+
for (const n of p.notes) lines.push(`! ${n}`);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
function formatSummary(results) {
|
|
75
|
+
const headers = ["DOMAIN", "ONBOARD"];
|
|
76
|
+
const rows = results.map((r) => [r.domain, r.onboard]);
|
|
77
|
+
return table(headers, rows, results.flatMap((r) => r.reminders));
|
|
78
|
+
}
|
|
79
|
+
/** One expected record vs. what DoH actually resolved for it. Pure — testable with no resolver. */
|
|
80
|
+
function dnsStatus(expected, resolved) {
|
|
81
|
+
return expected.map((record) => ({
|
|
82
|
+
record,
|
|
83
|
+
present: (resolved.get(record.name) ?? []).some((c) => c === record.content)
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/email/cloudflare.ts
|
|
89
|
+
/**
|
|
90
|
+
* Cloudflare Email Sending — read-only state plus the one write this module makes
|
|
91
|
+
* (`POST … /subdomains`, enable a sending name).
|
|
92
|
+
*
|
|
93
|
+
* `wrangler email sending list` has no `--json` (checked against wrangler 4.119.0),
|
|
94
|
+
* so this calls the same REST endpoints wrangler itself does rather than scraping
|
|
95
|
+
* its text table — the same reasoning `onboarding/cloudflare.ts` states for why it
|
|
96
|
+
* calls the API directly instead of shelling to a CLI.
|
|
97
|
+
*
|
|
98
|
+
* `fetchImpl` is injected, every call returns a structured result rather than
|
|
99
|
+
* throwing, and the caller decides what a failure means — the same contract as
|
|
100
|
+
* `onboarding/cloudflare.ts` and `ads/plan.ts`'s executor.
|
|
101
|
+
*/
|
|
102
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
103
|
+
const cfError = (status, body) => {
|
|
104
|
+
const first = body.errors?.[0];
|
|
105
|
+
return first ? `${first.code} ${first.message}` : `HTTP ${status}`;
|
|
106
|
+
};
|
|
107
|
+
const fromRaw = (s) => ({
|
|
108
|
+
name: s.name,
|
|
109
|
+
enabled: s.enabled,
|
|
110
|
+
tag: s.tag,
|
|
111
|
+
...s.dkim_selector ? { dkimSelector: s.dkim_selector } : {},
|
|
112
|
+
...s.return_path_domain ? { returnPathDomain: s.return_path_domain } : {}
|
|
113
|
+
});
|
|
114
|
+
/**
|
|
115
|
+
* Find the zone a host belongs to, walking labels from most to least specific —
|
|
116
|
+
* the same walk `wrangler`'s own `resolveDomain` does, because a sending name is
|
|
117
|
+
* usually a subdomain (`send.example.com`) and Cloudflare zones are registered at
|
|
118
|
+
* the apex. Tries `send.example.com`, then `example.com`, stopping at the first
|
|
119
|
+
* match. No public-suffix list: Cloudflare simply won't have a zone at a bare
|
|
120
|
+
* suffix like `co.uk`, so the walk is safe without one.
|
|
121
|
+
*/
|
|
122
|
+
async function findZoneForHost(token, host, fetchImpl = fetch) {
|
|
123
|
+
const labels = host.split(".");
|
|
124
|
+
let lastStatus = 0;
|
|
125
|
+
for (let i = 0; i <= labels.length - 2; i++) {
|
|
126
|
+
const candidate = labels.slice(i).join(".");
|
|
127
|
+
const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(candidate)}`, { headers: { authorization: `Bearer ${token}` } });
|
|
128
|
+
const body = await res.json();
|
|
129
|
+
lastStatus = res.status;
|
|
130
|
+
if (!res.ok || !body.success) return {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: res.status,
|
|
133
|
+
message: cfError(res.status, body)
|
|
134
|
+
};
|
|
135
|
+
const zone = body.result?.[0];
|
|
136
|
+
if (zone) return {
|
|
137
|
+
ok: true,
|
|
138
|
+
status: res.status,
|
|
139
|
+
zoneId: zone.id,
|
|
140
|
+
zoneName: zone.name
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: lastStatus,
|
|
146
|
+
message: `no Cloudflare zone found for "${host}" or any parent domain (is it in this account, and does the token carry Zone:Read?)`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Every sending name onboarded in one zone (`GET /zones/{id}/email/sending/subdomains`). */
|
|
150
|
+
async function listSendingSubdomains(token, zoneId, fetchImpl = fetch) {
|
|
151
|
+
const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, { headers: { authorization: `Bearer ${token}` } });
|
|
152
|
+
const body = await res.json();
|
|
153
|
+
if (!res.ok || !body.success) return {
|
|
154
|
+
ok: false,
|
|
155
|
+
status: res.status,
|
|
156
|
+
subdomains: [],
|
|
157
|
+
message: cfError(res.status, body)
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
status: res.status,
|
|
162
|
+
subdomains: (body.result ?? []).map(fromRaw)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Enable Email Sending for a name (`POST … /subdomains`). Create only — there is
|
|
167
|
+
* no update/delete verb here, matching every other onboarding executor's
|
|
168
|
+
* never-remove contract; disabling a live sending domain is left to the operator.
|
|
169
|
+
*/
|
|
170
|
+
async function enableSending(token, zoneId, name, fetchImpl = fetch) {
|
|
171
|
+
const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
authorization: `Bearer ${token}`,
|
|
175
|
+
"content-type": "application/json"
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({ name })
|
|
178
|
+
});
|
|
179
|
+
const body = await res.json();
|
|
180
|
+
if (!res.ok || !body.success) return {
|
|
181
|
+
ok: false,
|
|
182
|
+
status: res.status,
|
|
183
|
+
message: cfError(res.status, body)
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
status: res.status,
|
|
188
|
+
...body.result?.tag ? { tag: body.result.tag } : {}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** The DNS records Cloudflare expects for one sending name (`… /subdomains/{id}/dns`). */
|
|
192
|
+
async function sendingDns(token, zoneId, subdomainId, fetchImpl = fetch) {
|
|
193
|
+
const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns`, { headers: { authorization: `Bearer ${token}` } });
|
|
194
|
+
const body = await res.json();
|
|
195
|
+
if (!res.ok || !body.success) return {
|
|
196
|
+
ok: false,
|
|
197
|
+
status: res.status,
|
|
198
|
+
records: [],
|
|
199
|
+
message: cfError(res.status, body)
|
|
200
|
+
};
|
|
201
|
+
const records = (body.result ?? []).filter((r) => r.type === "MX" || r.type === "TXT").map((r) => ({
|
|
202
|
+
type: r.type,
|
|
203
|
+
name: r.name,
|
|
204
|
+
content: r.type === "TXT" ? r.content.replace(/^"|"$/g, "") : r.content,
|
|
205
|
+
...r.priority != null ? { priority: r.priority } : {}
|
|
206
|
+
}));
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
status: res.status,
|
|
210
|
+
records
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/email/dns.ts
|
|
216
|
+
/**
|
|
217
|
+
* DNS-over-HTTPS propagation probe.
|
|
218
|
+
*
|
|
219
|
+
* Nothing in this repo uses `node:dns` (checked before adding this file — see
|
|
220
|
+
* `AGENTS.md`'s Conversion tracking section for the same reasoning applied to
|
|
221
|
+
* onboarding), and a DNS resolution needs no credential, so DoH is a plain
|
|
222
|
+
* `fetch` rather than a new runtime dependency. This confirms the records
|
|
223
|
+
* Cloudflare's API says it created actually resolve — the Cloudflare API telling
|
|
224
|
+
* you a record exists and the internet being able to see it are different facts,
|
|
225
|
+
* and only the second one lets a visitor's mail actually authenticate.
|
|
226
|
+
*
|
|
227
|
+
* Polling policy (how many attempts, how long between them) is not here —
|
|
228
|
+
* `onboarding/plan.ts`'s `backoffSchedule()` already is, and the CLI does the
|
|
229
|
+
* sleeping, exactly as `search setup`'s DNS_TXT verification retry does.
|
|
230
|
+
*/
|
|
231
|
+
const DOH = "https://cloudflare-dns.com/dns-query";
|
|
232
|
+
/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */
|
|
233
|
+
async function resolveTxt(name, fetchImpl = fetch) {
|
|
234
|
+
const res = await fetchImpl(`${DOH}?name=${encodeURIComponent(name)}&type=TXT`, { headers: { accept: "application/dns-json" } });
|
|
235
|
+
if (!res.ok) return {
|
|
236
|
+
ok: false,
|
|
237
|
+
contents: [],
|
|
238
|
+
message: `HTTP ${res.status}`
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
contents: ((await res.json()).Answer ?? []).map((a) => a.data.replace(/^"|"$/g, ""))
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/email/scan.ts
|
|
248
|
+
/**
|
|
249
|
+
* `\b` isn't enough of a left boundary: `-` is a non-word character, so a bare
|
|
250
|
+
* `\bfrom` would match inside `data-from=`. Requiring the previous character be
|
|
251
|
+
* neither a word character nor a hyphen keeps the match to a genuine identifier.
|
|
252
|
+
*/
|
|
253
|
+
const LEFT = String.raw`(?<![\w-])`;
|
|
254
|
+
/** A quoted literal, a template literal (rejected if it interpolates), an object literal, or a bare expression. */
|
|
255
|
+
const VALUE = String.raw`(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`|(\{[^}]*\})|([A-Za-z_$][\w$.]*(?:\([^)]*\))?))`;
|
|
256
|
+
const FROM = new RegExp(String.raw`${LEFT}from\s*:\s*${VALUE}`, "g");
|
|
257
|
+
/** `email:`/`address:` inside an already-isolated object-literal slice. */
|
|
258
|
+
const KEY = /(?<![\w-])(?:email|address)\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`$]*)`)/;
|
|
259
|
+
const lineOf = (src, index) => src.slice(0, index).split("\n").length;
|
|
260
|
+
/** Extract every `from` reference from one file's text. */
|
|
261
|
+
function extractFromRefs(src, file = "<source>") {
|
|
262
|
+
const refs = [];
|
|
263
|
+
const dynamic = [];
|
|
264
|
+
for (const m of src.matchAll(FROM)) {
|
|
265
|
+
const [dq, sq, tpl, obj, ident] = m.slice(1);
|
|
266
|
+
const line = lineOf(src, m.index);
|
|
267
|
+
if (obj !== void 0) {
|
|
268
|
+
const hit = KEY.exec(obj);
|
|
269
|
+
const literal = hit?.[1] ?? hit?.[2] ?? hit?.[3];
|
|
270
|
+
if (literal !== void 0 && !literal.includes("${")) refs.push({
|
|
271
|
+
address: literal,
|
|
272
|
+
kind: "object",
|
|
273
|
+
file,
|
|
274
|
+
line
|
|
275
|
+
});
|
|
276
|
+
else dynamic.push({
|
|
277
|
+
expr: obj,
|
|
278
|
+
file,
|
|
279
|
+
line
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (ident !== void 0) {
|
|
284
|
+
dynamic.push({
|
|
285
|
+
expr: ident,
|
|
286
|
+
file,
|
|
287
|
+
line
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const literal = dq ?? sq ?? tpl;
|
|
292
|
+
if (literal === void 0) continue;
|
|
293
|
+
if (literal.includes("${")) {
|
|
294
|
+
dynamic.push({
|
|
295
|
+
expr: literal,
|
|
296
|
+
file,
|
|
297
|
+
line
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (literal.includes("@")) refs.push({
|
|
302
|
+
address: literal,
|
|
303
|
+
kind: "bare",
|
|
304
|
+
file,
|
|
305
|
+
line
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
refs,
|
|
310
|
+
dynamic
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Run `extractFromRefs` across a file set and collect unique addresses. */
|
|
314
|
+
function collectFromAddresses(files) {
|
|
315
|
+
const refs = [];
|
|
316
|
+
const dynamic = [];
|
|
317
|
+
for (const f of files) {
|
|
318
|
+
const r = extractFromRefs(f.text, f.path);
|
|
319
|
+
refs.push(...r.refs);
|
|
320
|
+
dynamic.push(...r.dynamic);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
refs,
|
|
324
|
+
dynamic,
|
|
325
|
+
addresses: [...new Set(refs.map((r) => r.address))]
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
//#endregion
|
|
330
|
+
export { backoffSchedule, blockedDomains, collectFromAddresses, dnsStatus, enableSending, extractFromRefs, findZoneForHost, formatPlan, formatSummary, hasDrift, listSendingSubdomains, planDomain, planSendingDomains, resolveTxt, sendingDns };
|
|
331
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/email/plan.ts","../../src/email/cloudflare.ts","../../src/email/dns.ts","../../src/email/scan.ts"],"sourcesContent":["/**\n * Every decision `vitops email setup`/`vitops email check` makes, as one pure\n * function.\n *\n * Pure in the load-bearing sense: no `fetch`, no clock. The executors beside this\n * one (`cloudflare.ts`, `dns.ts`) gather live state and apply what they are told;\n * this module decides, from that state, what is left to do — the same split\n * `ads/plan.ts` and `onboarding/plan.ts` make, for the same two payoffs: `--dry`\n * is a complete account of a run, and `--check`/`email check` is an honest drift\n * report.\n *\n * One rule specific to this planner, discovered against a live account: **the\n * onboarded entity is an exact name**, and it is usually a subdomain\n * (`send.example.com`), never the apex a `from` address's domain names. So the\n * most valuable thing this planner can report is not \"onboard this domain\" but\n * \"a sibling in the same zone already IS onboarded — send from there instead.\"\n * That is why a domain absent-with-a-sibling-present is `blocked`, not `create`:\n * creating the apex too is the second option, and it carries a DMARC caveat (see\n * `AGENTS.md`'s Conversion tracking section) that a bare `create` step would hide.\n */\nimport type { EmailConfig, EmailProvider, FromCandidate, ZoneSendingState } from './types.ts';\nimport { table } from '../plan-format.ts';\n\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, what the mutation will do, or what a human should do instead. */\n detail: string;\n}\n\nexport interface SendingDomainPlan {\n domain: string;\n /** Where this domain came from — config, derived, or the source scan. */\n sources: FromCandidate['source'][];\n onboard: StepPlan;\n reminders: string[];\n}\n\nexport interface EmailPlan {\n domains: SendingDomainPlan[];\n notes: string[];\n}\n\n/** Decide one domain's step from its zone's observed sending names. */\nexport function planDomain(\n domain: string,\n provider: EmailProvider,\n state: ZoneSendingState | undefined,\n): StepPlan {\n if (provider !== 'cloudflare')\n return { action: 'skip', detail: `no onboarding check implemented for \"${provider}\"` };\n\n if (!state)\n return {\n action: 'blocked',\n detail: `no observed Cloudflare state for \"${domain}\" — is it in this account? (needs CLOUDFLARE_API_TOKEN with Zone:Read + an Email Sending scope)`,\n };\n\n const exact = state.subdomains.find((s) => s.name === domain);\n if (exact) {\n return exact.enabled\n ? { action: 'skip', detail: `already onboarded and enabled` }\n : {\n action: 'create',\n detail: `onboarded but disabled — \\`wrangler email sending enable ${domain}\\` (or re-enable in the dashboard)`,\n };\n }\n\n const sibling = state.subdomains.find((s) => s.enabled);\n if (sibling) {\n return {\n action: 'blocked',\n detail:\n `\"${domain}\" is not onboarded, but \"${sibling.name}\" already is in the same zone — ` +\n `send from \"@${sibling.name}\" instead (isolates transactional mail's DMARC/reputation ` +\n `from the domain's regular mail), or run \\`wrangler email sending enable ${domain}\\` ` +\n `to onboard the apex too (this rewrites _dmarc.${domain} — check what's there first)`,\n };\n }\n\n return {\n action: 'create',\n detail: `not onboarded — \\`wrangler email sending enable ${domain}\\``,\n };\n}\n\n/** Plan every candidate domain. One entry per distinct domain, sources merged. */\nexport function planSendingDomains(\n config: EmailConfig,\n states: Map<string, ZoneSendingState>,\n): EmailPlan {\n const byDomain = new Map<string, FromCandidate['source'][]>();\n for (const c of config.candidates) {\n const sources = byDomain.get(c.domain) ?? [];\n if (!sources.includes(c.source)) sources.push(c.source);\n byDomain.set(c.domain, sources);\n }\n\n const domains: SendingDomainPlan[] = [...byDomain.entries()].map(([domain, sources]) => {\n const onboard = planDomain(domain, config.provider, states.get(domain));\n const reminders =\n onboard.action === 'create' && config.provider === 'cloudflare'\n ? ['DNS takes minutes to propagate before mail sent from it will authenticate.']\n : [];\n return { domain, sources, onboard, reminders };\n });\n\n return { domains, notes: [] };\n}\n\n/** Any step that isn't a skip — i.e. drift, worth reporting. */\nexport function hasDrift(p: EmailPlan): boolean {\n return p.domains.some((d) => d.onboard.action !== 'skip');\n}\n\n/** Every domain still blocked, for a summary that names what's outstanding. */\nexport function blockedDomains(p: EmailPlan): SendingDomainPlan[] {\n return p.domains.filter((d) => d.onboard.action === 'blocked');\n}\n\n/** Render a plan as the block `--dry`/`--check`/`email check` print. */\nexport function formatPlan(p: EmailPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const d of p.domains) {\n lines.push(`${d.domain} (${d.sources.join(', ')})`);\n lines.push(` ${glyph(d.onboard)} ${d.onboard.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n onboard: 'created' | 'present' | 'blocked' | 'failed';\n reminders: string[];\n}\n\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'ONBOARD'];\n const rows = results.map((r) => [r.domain, r.onboard]);\n return table(\n headers,\n rows,\n results.flatMap((r) => r.reminders),\n );\n}\n\n/** One expected record vs. what DoH actually resolved for it. Pure — testable with no resolver. */\nexport function dnsStatus(\n expected: { type: 'MX' | 'TXT'; name: string; content: string }[],\n resolved: Map<string, string[]>,\n): { record: (typeof expected)[number]; present: boolean }[] {\n return expected.map((record) => ({\n record,\n present: (resolved.get(record.name) ?? []).some((c) => c === record.content),\n }));\n}\n","/**\n * Cloudflare Email Sending — read-only state plus the one write this module makes\n * (`POST … /subdomains`, enable a sending name).\n *\n * `wrangler email sending list` has no `--json` (checked against wrangler 4.119.0),\n * so this calls the same REST endpoints wrangler itself does rather than scraping\n * its text table — the same reasoning `onboarding/cloudflare.ts` states for why it\n * calls the API directly instead of shelling to a CLI.\n *\n * `fetchImpl` is injected, every call returns a structured result rather than\n * throwing, and the caller decides what a failure means — the same contract as\n * `onboarding/cloudflare.ts` and `ads/plan.ts`'s executor.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\ninterface RawSubdomain {\n name: string;\n enabled: boolean;\n tag: string;\n dkim_selector?: string;\n return_path_domain?: string;\n}\n\nconst fromRaw = (s: RawSubdomain) => ({\n name: s.name,\n enabled: s.enabled,\n tag: s.tag,\n ...(s.dkim_selector ? { dkimSelector: s.dkim_selector } : {}),\n ...(s.return_path_domain ? { returnPathDomain: s.return_path_domain } : {}),\n});\n\n/**\n * Find the zone a host belongs to, walking labels from most to least specific —\n * the same walk `wrangler`'s own `resolveDomain` does, because a sending name is\n * usually a subdomain (`send.example.com`) and Cloudflare zones are registered at\n * the apex. Tries `send.example.com`, then `example.com`, stopping at the first\n * match. No public-suffix list: Cloudflare simply won't have a zone at a bare\n * suffix like `co.uk`, so the walk is safe without one.\n */\nexport async function findZoneForHost(\n token: string,\n host: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; zoneName?: string; message?: string }> {\n const labels = host.split('.');\n let lastStatus = 0;\n for (let i = 0; i <= labels.length - 2; i++) {\n const candidate = labels.slice(i).join('.');\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(candidate)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n lastStatus = res.status;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (zone) return { ok: true, status: res.status, zoneId: zone.id, zoneName: zone.name };\n }\n return {\n ok: false,\n status: lastStatus,\n message: `no Cloudflare zone found for \"${host}\" or any parent domain (is it in this account, and does the token carry Zone:Read?)`,\n };\n}\n\n/** Every sending name onboarded in one zone (`GET /zones/{id}/email/sending/subdomains`). */\nexport async function listSendingSubdomains(\n token: string,\n zoneId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n subdomains: {\n name: string;\n enabled: boolean;\n tag: string;\n dkimSelector?: string;\n returnPathDomain?: string;\n }[];\n message?: string;\n}> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, subdomains: [], message: cfError(res.status, body) };\n return { ok: true, status: res.status, subdomains: (body.result ?? []).map(fromRaw) };\n}\n\n/**\n * Enable Email Sending for a name (`POST … /subdomains`). Create only — there is\n * no update/delete verb here, matching every other onboarding executor's\n * never-remove contract; disabling a live sending domain is left to the operator.\n */\nexport async function enableSending(\n token: string,\n zoneId: string,\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; tag?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/email/sending/subdomains`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ name }),\n });\n const body = (await res.json()) as CfEnvelope<RawSubdomain>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n // `tag` doubles as the subdomain id `sendingDns` addresses — Cloudflare's\n // response names it `tag`, not `id`.\n return { ok: true, status: res.status, ...(body.result?.tag ? { tag: body.result.tag } : {}) };\n}\n\ninterface RawDnsRecord {\n type: string;\n name: string;\n content: string;\n priority?: number;\n}\n\n/** The DNS records Cloudflare expects for one sending name (`… /subdomains/{id}/dns`). */\nexport async function sendingDns(\n token: string,\n zoneId: string,\n subdomainId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n records: { type: 'MX' | 'TXT'; name: string; content: string; priority?: number }[];\n message?: string;\n}> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<RawDnsRecord[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, records: [], message: cfError(res.status, body) };\n const records = (body.result ?? [])\n .filter((r): r is RawDnsRecord & { type: 'MX' | 'TXT' } => r.type === 'MX' || r.type === 'TXT')\n .map((r) => ({\n type: r.type,\n name: r.name,\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n content: r.type === 'TXT' ? r.content.replace(/^\"|\"$/g, '') : r.content,\n ...(r.priority != null ? { priority: r.priority } : {}),\n }));\n return { ok: true, status: res.status, records };\n}\n","/**\n * DNS-over-HTTPS propagation probe.\n *\n * Nothing in this repo uses `node:dns` (checked before adding this file — see\n * `AGENTS.md`'s Conversion tracking section for the same reasoning applied to\n * onboarding), and a DNS resolution needs no credential, so DoH is a plain\n * `fetch` rather than a new runtime dependency. This confirms the records\n * Cloudflare's API says it created actually resolve — the Cloudflare API telling\n * you a record exists and the internet being able to see it are different facts,\n * and only the second one lets a visitor's mail actually authenticate.\n *\n * Polling policy (how many attempts, how long between them) is not here —\n * `onboarding/plan.ts`'s `backoffSchedule()` already is, and the CLI does the\n * sleeping, exactly as `search setup`'s DNS_TXT verification retry does.\n */\nconst DOH = 'https://cloudflare-dns.com/dns-query';\n\ninterface DohAnswer {\n name: string;\n type: number;\n data: string;\n}\n\ninterface DohResponse {\n Status: number;\n Answer?: DohAnswer[];\n}\n\n/** Resolve TXT records for a name over DoH. `NXDOMAIN`/no-answer both return `[]`. */\nexport async function resolveTxt(\n name: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; contents: string[]; message?: string }> {\n const res = await fetchImpl(`${DOH}?name=${encodeURIComponent(name)}&type=TXT`, {\n headers: { accept: 'application/dns-json' },\n });\n if (!res.ok) return { ok: false, contents: [], message: `HTTP ${res.status}` };\n const body = (await res.json()) as DohResponse;\n const contents = (body.Answer ?? []).map((a) => a.data.replace(/^\"|\"$/g, ''));\n return { ok: true, contents };\n}\n","/**\n * Best-effort static scan for `from` addresses in consumer source.\n *\n * Config already has the primary source of truth (`site.notifications.email.from`,\n * plus `resolveSender`'s derived default) — this exists only to catch a hand-rolled\n * `EMAIL.send()` call outside `createConversionRoute()`, which the config cannot see.\n *\n * Same judgement call as `icons-scan.ts`'s `extractIconRefs`, for the same reason:\n * a computed value (`from: getSender()`, `from: { email: cfg.from }`) is REPORTED\n * as dynamic, never guessed at. Guessing would produce a candidate list that is\n * confidently wrong, which is worse than one that is honestly incomplete — and\n * it is exactly the false-positive case `--skip`/`verifySender: false` exists for.\n */\n\n/** A statically resolvable `from` address. */\nexport interface FromRef {\n address: string;\n /** `bare` — `from: 'x@y.com'`. `object` — `from: { email: '…' }` / `{ address: '…' }`. */\n kind: 'bare' | 'object';\n file: string;\n line: number;\n}\n\n/** A `from` value that cannot be read statically. */\nexport interface DynamicFromRef {\n expr: string;\n file: string;\n line: number;\n}\n\nexport interface FromScanResult {\n refs: FromRef[];\n dynamic: DynamicFromRef[];\n /** Unique addresses, in first-seen order. */\n addresses: string[];\n}\n\n/**\n * `\\b` isn't enough of a left boundary: `-` is a non-word character, so a bare\n * `\\bfrom` would match inside `data-from=`. Requiring the previous character be\n * neither a word character nor a hyphen keeps the match to a genuine identifier.\n */\nconst LEFT = String.raw`(?<![\\w-])`;\n\n/** A quoted literal, a template literal (rejected if it interpolates), an object literal, or a bare expression. */\nconst VALUE = String.raw`(?:\"([^\"]*)\"|'([^']*)'|\\`([^\\`]*)\\`|(\\{[^}]*\\})|([A-Za-z_$][\\w$.]*(?:\\([^)]*\\))?))`;\n\nconst FROM = new RegExp(String.raw`${LEFT}from\\s*:\\s*${VALUE}`, 'g');\n\n/** `email:`/`address:` inside an already-isolated object-literal slice. */\nconst KEY = /(?<![\\w-])(?:email|address)\\s*:\\s*(?:\"([^\"]*)\"|'([^']*)'|`([^`$]*)`)/;\n\nconst lineOf = (src: string, index: number) => src.slice(0, index).split('\\n').length;\n\n/** Extract every `from` reference from one file's text. */\nexport function extractFromRefs(\n src: string,\n file = '<source>',\n): { refs: FromRef[]; dynamic: DynamicFromRef[] } {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n\n for (const m of src.matchAll(FROM)) {\n const [dq, sq, tpl, obj, ident] = m.slice(1);\n const line = lineOf(src, m.index!);\n\n if (obj !== undefined) {\n const hit = KEY.exec(obj);\n const literal = hit?.[1] ?? hit?.[2] ?? hit?.[3];\n if (literal !== undefined && !literal.includes('${'))\n refs.push({ address: literal, kind: 'object', file, line });\n else dynamic.push({ expr: obj, file, line });\n continue;\n }\n\n if (ident !== undefined) {\n dynamic.push({ expr: ident, file, line });\n continue;\n }\n\n const literal = dq ?? sq ?? tpl;\n if (literal === undefined) continue;\n if (literal.includes('${')) {\n dynamic.push({ expr: literal, file, line });\n continue;\n }\n // A bare `from` string that isn't an email address is very likely an\n // unrelated field (a display name, a form field) — only an '@' makes this a\n // genuine candidate.\n if (literal.includes('@')) refs.push({ address: literal, kind: 'bare', file, line });\n }\n\n return { refs, dynamic };\n}\n\n/** Run `extractFromRefs` across a file set and collect unique addresses. */\nexport function collectFromAddresses(\n files: readonly { path: string; text: string }[],\n): FromScanResult {\n const refs: FromRef[] = [];\n const dynamic: DynamicFromRef[] = [];\n for (const f of files) {\n const r = extractFromRefs(f.text, f.path);\n refs.push(...r.refs);\n dynamic.push(...r.dynamic);\n }\n return { refs, dynamic, addresses: [...new Set(refs.map((r) => r.address))] };\n}\n"],"mappings":";;;;;AA6CA,SAAgB,WACd,QACA,UACA,OACU;CACV,IAAI,aAAa,cACf,OAAO;EAAE,QAAQ;EAAQ,QAAQ,wCAAwC,SAAS;CAAG;CAEvF,IAAI,CAAC,OACH,OAAO;EACL,QAAQ;EACR,QAAQ,qCAAqC,OAAO;CACtD;CAEF,MAAM,QAAQ,MAAM,WAAW,MAAM,MAAM,EAAE,SAAS,MAAM;CAC5D,IAAI,OACF,OAAO,MAAM,UACT;EAAE,QAAQ;EAAQ,QAAQ;CAAgC,IAC1D;EACE,QAAQ;EACR,QAAQ,4DAA4D,OAAO;CAC7E;CAGN,MAAM,UAAU,MAAM,WAAW,MAAM,MAAM,EAAE,OAAO;CACtD,IAAI,SACF,OAAO;EACL,QAAQ;EACR,QACE,IAAI,OAAO,2BAA2B,QAAQ,KAAK,8CACpC,QAAQ,KAAK,oIAC+C,OAAO,mDACjC,OAAO;CAC5D;CAGF,OAAO;EACL,QAAQ;EACR,QAAQ,mDAAmD,OAAO;CACpE;AACF;;AAGA,SAAgB,mBACd,QACA,QACW;CACX,MAAM,2BAAW,IAAI,IAAuC;CAC5D,KAAK,MAAM,KAAK,OAAO,YAAY;EACjC,MAAM,UAAU,SAAS,IAAI,EAAE,MAAM,KAAK,CAAC;EAC3C,IAAI,CAAC,QAAQ,SAAS,EAAE,MAAM,GAAG,QAAQ,KAAK,EAAE,MAAM;EACtD,SAAS,IAAI,EAAE,QAAQ,OAAO;CAChC;CAWA,OAAO;EAAE,SAT4B,CAAC,GAAG,SAAS,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,aAAa;GACtF,MAAM,UAAU,WAAW,QAAQ,OAAO,UAAU,OAAO,IAAI,MAAM,CAAC;GAKtE,OAAO;IAAE;IAAQ;IAAS;IAAS,WAHjC,QAAQ,WAAW,YAAY,OAAO,aAAa,eAC/C,CAAC,4EAA4E,IAC7E,CAAC;GACsC;EAC/C,CAEe;EAAG,OAAO,CAAC;CAAE;AAC9B;;AAGA,SAAgB,SAAS,GAAuB;CAC9C,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,WAAW,MAAM;AAC1D;;AAGA,SAAgB,eAAe,GAAmC;CAChE,OAAO,EAAE,QAAQ,QAAQ,MAAM,EAAE,QAAQ,WAAW,SAAS;AAC/D;;AAGA,SAAgB,WAAW,GAAsB;CAC/C,MAAM,SAAS,MAAiB,EAAE,WAAW,SAAS,MAAM,EAAE,WAAW,YAAY,MAAM;CAC3F,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,KAAK,EAAE,SAAS;EACzB,MAAM,KAAK,GAAG,EAAE,OAAO,KAAK,EAAE,QAAQ,KAAK,IAAI,EAAE,EAAE;EACnD,MAAM,KAAK,KAAK,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,QAAQ;EACtD,KAAK,MAAM,KAAK,EAAE,WAAW,MAAM,KAAK,OAAO,GAAG;CACpD;CACA,KAAK,MAAM,KAAK,EAAE,OAAO,MAAM,KAAK,KAAK,GAAG;CAC5C,OAAO,MAAM,KAAK,IAAI;AACxB;AASA,SAAgB,cAAc,SAAiC;CAC7D,MAAM,UAAU,CAAC,UAAU,SAAS;CACpC,MAAM,OAAO,QAAQ,KAAK,MAAM,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC;CACrD,OAAO,MACL,SACA,MACA,QAAQ,SAAS,MAAM,EAAE,SAAS,CACpC;AACF;;AAGA,SAAgB,UACd,UACA,UAC2D;CAC3D,OAAO,SAAS,KAAK,YAAY;EAC/B;EACA,UAAU,SAAS,IAAI,OAAO,IAAI,KAAK,CAAC,EAAC,CAAE,MAAM,MAAM,MAAM,OAAO,OAAO;CAC7E,EAAE;AACJ;;;;;;;;;;;;;;;;;ACnJA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;AAUA,MAAM,WAAW,OAAqB;CACpC,MAAM,EAAE;CACR,SAAS,EAAE;CACX,KAAK,EAAE;CACP,GAAI,EAAE,gBAAgB,EAAE,cAAc,EAAE,cAAc,IAAI,CAAC;CAC3D,GAAI,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,mBAAmB,IAAI,CAAC;AAC3E;;;;;;;;;AAUA,eAAsB,gBACpB,OACA,MACA,YAA0B,OACsE;CAChG,MAAM,SAAS,KAAK,MAAM,GAAG;CAC7B,IAAI,aAAa;CACjB,KAAK,IAAI,IAAI,GAAG,KAAK,OAAO,SAAS,GAAG,KAAK;EAC3C,MAAM,YAAY,OAAO,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG;EAC1C,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,SAAS,KAAK,EAChF,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;EACD,MAAM,OAAQ,MAAM,IAAI,KAAK;EAC7B,aAAa,IAAI;EACjB,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;GAAE,IAAI;GAAO,QAAQ,IAAI;GAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;EAAE;EAC7E,MAAM,OAAO,KAAK,SAAS;EAC3B,IAAI,MAAM,OAAO;GAAE,IAAI;GAAM,QAAQ,IAAI;GAAQ,QAAQ,KAAK;GAAI,UAAU,KAAK;EAAK;CACxF;CACA,OAAO;EACL,IAAI;EACJ,QAAQ;EACR,SAAS,iCAAiC,KAAK;CACjD;AACF;;AAGA,eAAsB,sBACpB,OACA,QACA,YAA0B,OAYzB;CACD,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,4BAA4B,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,YAAY,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7F,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,aAAa,KAAK,UAAU,CAAC,EAAC,CAAE,IAAI,OAAO;CAAE;AACtF;;;;;;AAOA,eAAsB,cACpB,OACA,QACA,MACA,YAA0B,OACgD;CAC1E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,4BAA4B;EAC7E,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC;CAC/B,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAG7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,GAAI,KAAK,QAAQ,MAAM,EAAE,KAAK,KAAK,OAAO,IAAI,IAAI,CAAC;CAAG;AAC/F;;AAUA,eAAsB,WACpB,OACA,QACA,aACA,YAA0B,OAMzB;CACD,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,4BAA4B,YAAY,OAC/D,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC1F,MAAM,WAAW,KAAK,UAAU,CAAC,EAAC,CAC/B,QAAQ,MAAkD,EAAE,SAAS,QAAQ,EAAE,SAAS,KAAK,CAAC,CAC9F,KAAK,OAAO;EACX,MAAM,EAAE;EACR,MAAM,EAAE;EAER,SAAS,EAAE,SAAS,QAAQ,EAAE,QAAQ,QAAQ,UAAU,EAAE,IAAI,EAAE;EAChE,GAAI,EAAE,YAAY,OAAO,EAAE,UAAU,EAAE,SAAS,IAAI,CAAC;CACvD,EAAE;CACJ,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAQ;AACjD;;;;;;;;;;;;;;;;;;;ACnJA,MAAM,MAAM;;AAcZ,eAAsB,WACpB,MACA,YAA0B,OACsC;CAChE,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,QAAQ,mBAAmB,IAAI,EAAE,YAAY,EAC9E,SAAS,EAAE,QAAQ,uBAAuB,EAC5C,CAAC;CACD,IAAI,CAAC,IAAI,IAAI,OAAO;EAAE,IAAI;EAAO,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI;CAAS;CAG7E,OAAO;EAAE,IAAI;EAAM,YADD,MADE,IAAI,KAAK,EACP,CAAC,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,UAAU,EAAE,CACjD;CAAE;AAC9B;;;;;;;;;ACEA,MAAM,OAAO,OAAO,GAAG;;AAGvB,MAAM,QAAQ,OAAO,GAAG;AAExB,MAAM,OAAO,IAAI,OAAO,OAAO,GAAG,GAAG,KAAK,aAAa,SAAS,GAAG;;AAGnE,MAAM,MAAM;AAEZ,MAAM,UAAU,KAAa,UAAkB,IAAI,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC;;AAG/E,SAAgB,gBACd,KACA,OAAO,YACyC;CAChD,MAAM,OAAkB,CAAC;CACzB,MAAM,UAA4B,CAAC;CAEnC,KAAK,MAAM,KAAK,IAAI,SAAS,IAAI,GAAG;EAClC,MAAM,CAAC,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,MAAM,CAAC;EAC3C,MAAM,OAAO,OAAO,KAAK,EAAE,KAAM;EAEjC,IAAI,QAAQ,QAAW;GACrB,MAAM,MAAM,IAAI,KAAK,GAAG;GACxB,MAAM,UAAU,MAAM,MAAM,MAAM,MAAM,MAAM;GAC9C,IAAI,YAAY,UAAa,CAAC,QAAQ,SAAS,IAAI,GACjD,KAAK,KAAK;IAAE,SAAS;IAAS,MAAM;IAAU;IAAM;GAAK,CAAC;QACvD,QAAQ,KAAK;IAAE,MAAM;IAAK;IAAM;GAAK,CAAC;GAC3C;EACF;EAEA,IAAI,UAAU,QAAW;GACvB,QAAQ,KAAK;IAAE,MAAM;IAAO;IAAM;GAAK,CAAC;GACxC;EACF;EAEA,MAAM,UAAU,MAAM,MAAM;EAC5B,IAAI,YAAY,QAAW;EAC3B,IAAI,QAAQ,SAAS,IAAI,GAAG;GAC1B,QAAQ,KAAK;IAAE,MAAM;IAAS;IAAM;GAAK,CAAC;GAC1C;EACF;EAIA,IAAI,QAAQ,SAAS,GAAG,GAAG,KAAK,KAAK;GAAE,SAAS;GAAS,MAAM;GAAQ;GAAM;EAAK,CAAC;CACrF;CAEA,OAAO;EAAE;EAAM;CAAQ;AACzB;;AAGA,SAAgB,qBACd,OACgB;CAChB,MAAM,OAAkB,CAAC;CACzB,MAAM,UAA4B,CAAC;CACnC,KAAK,MAAM,KAAK,OAAO;EACrB,MAAM,IAAI,gBAAgB,EAAE,MAAM,EAAE,IAAI;EACxC,KAAK,KAAK,GAAG,EAAE,IAAI;EACnB,QAAQ,KAAK,GAAG,EAAE,OAAO;CAC3B;CACA,OAAO;EAAE;EAAM;EAAS,WAAW,CAAC,GAAG,IAAI,IAAI,KAAK,KAAK,MAAM,EAAE,OAAO,CAAC,CAAC;CAAE;AAC9E"}
|
package/dist/favicon.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/favicon.d.ts
|
|
2
|
-
interface FaviconOptions {
|
|
2
|
+
export interface FaviconOptions {
|
|
3
3
|
/** Source image — an SVG or PNG. */
|
|
4
4
|
source: string;
|
|
5
5
|
/** Optional simplified source for the 16px icon. */
|
|
@@ -27,14 +27,14 @@ interface FaviconOptions {
|
|
|
27
27
|
* `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.
|
|
28
28
|
* Returns the list of written file paths.
|
|
29
29
|
*/
|
|
30
|
-
declare function generateFavicons(opts: FaviconOptions): Promise<string[]>;
|
|
31
|
-
interface FaviconLink {
|
|
30
|
+
export declare function generateFavicons(opts: FaviconOptions): Promise<string[]>;
|
|
31
|
+
export interface FaviconLink {
|
|
32
32
|
rel: string;
|
|
33
33
|
href: string;
|
|
34
34
|
type?: string;
|
|
35
35
|
sizes?: string;
|
|
36
36
|
}
|
|
37
|
-
interface FaviconTagOptions {
|
|
37
|
+
export interface FaviconTagOptions {
|
|
38
38
|
/** Whether a favicon.svg was emitted (source was an SVG). */
|
|
39
39
|
hasSvg?: boolean;
|
|
40
40
|
/** Include the `<link rel="manifest">` (default true). */
|
|
@@ -46,8 +46,8 @@ interface FaviconTagOptions {
|
|
|
46
46
|
* The ordered `<link>` data matching what `generateFavicons` writes. Pure — no
|
|
47
47
|
* I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.
|
|
48
48
|
*/
|
|
49
|
-
declare function faviconLinks(opts?: FaviconTagOptions): FaviconLink[];
|
|
50
|
-
interface WebManifestOptions {
|
|
49
|
+
export declare function faviconLinks(opts?: FaviconTagOptions): FaviconLink[];
|
|
50
|
+
export interface WebManifestOptions {
|
|
51
51
|
name: string;
|
|
52
52
|
shortName?: string;
|
|
53
53
|
themeColor?: string;
|
|
@@ -56,9 +56,8 @@ interface WebManifestOptions {
|
|
|
56
56
|
base?: string;
|
|
57
57
|
}
|
|
58
58
|
/** Build the web-app-manifest object (icons match `generateFavicons` output). */
|
|
59
|
-
declare function faviconManifest(opts: WebManifestOptions): Record<string, unknown>;
|
|
59
|
+
export declare function faviconManifest(opts: WebManifestOptions): Record<string, unknown>;
|
|
60
60
|
/** Write `site.webmanifest` into `dir`. Returns the written path. */
|
|
61
|
-
declare function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string>;
|
|
61
|
+
export declare function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string>;
|
|
62
62
|
//#endregion
|
|
63
|
-
export { FaviconLink, FaviconOptions, FaviconTagOptions, WebManifestOptions, faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
|
|
64
63
|
//# sourceMappingURL=favicon.d.mts.map
|
package/dist/favicon.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";iBAiBiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;EAaA;;;;;;;wBA0EoB,iBAAiB,MAAM,iBAAiB;iBA4G7C;EACf;EACA;EACA;EACA;;iBAGe;;EAEf;;EAEA;;EAEA;;;;;;wBAOc,aAAa,OAAM,oBAAyB;iBAW3C;EACf;EACA;EACA;EACA;;EAEA;;;wBAIc,gBAAgB,MAAM,qBAAqB;;wBAuBrC,qBAAqB,aAAa,MAAM,qBAAqB"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
//#region src/git/github.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* GitHub's Contents API — `commitFile()` creates or updates a single file with
|
|
4
|
+
* one commit, called from the EmDash design-system-editor plugin route (see
|
|
5
|
+
* AGENTS.md's "EmDash plugin as the design-system editing surface" section)
|
|
6
|
+
* to auto-sync a client's admin edit back into the git-tracked `site.json`.
|
|
7
|
+
*
|
|
8
|
+
* `fetch`-only, no `gh` CLI — a deployed Cloudflare Worker has neither a
|
|
9
|
+
* filesystem nor `child_process`, so this can't shell out the way
|
|
10
|
+
* `packages/utils/src/hosting/github.ts` does for a local `vitops setup repo`
|
|
11
|
+
* run. Same "injected `fetchImpl`, structured result, never throws" shape as
|
|
12
|
+
* `onboarding/cloudflare.ts` and `security/turnstile.ts` — a Worker route
|
|
13
|
+
* calling this has already decided a commit failure must degrade (the
|
|
14
|
+
* immediate override still applies), never fail the request.
|
|
15
|
+
*
|
|
16
|
+
* The token is a fine-grained PAT scoped to one repository with `Contents:
|
|
17
|
+
* Read and write` only — an env var (`VITOPS_GITHUB_TOKEN`), never config,
|
|
18
|
+
* the same rule every other credential in this toolchain follows. Branch
|
|
19
|
+
* protection on `main` (provisioned by `vitops setup repo`) is the real
|
|
20
|
+
* backstop against this token being used for anything beyond the integration
|
|
21
|
+
* branch it's meant to commit to, regardless of what it nominally permits.
|
|
22
|
+
*/
|
|
23
|
+
interface CommitFileOptions {
|
|
24
|
+
/** Repository owner (user or org). */
|
|
25
|
+
owner: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
/** Branch to commit to — never `main`/production directly, see module docblock. */
|
|
28
|
+
branch: string;
|
|
29
|
+
/** File path within the repo, e.g. `"site.json"`. */
|
|
30
|
+
path: string;
|
|
31
|
+
/** The full new file content (already merged — this call replaces it whole). */
|
|
32
|
+
content: string;
|
|
33
|
+
/** Commit message. */
|
|
34
|
+
message: string;
|
|
35
|
+
/** Fine-grained PAT, `Contents: Read and write` on this repo only. */
|
|
36
|
+
token: string;
|
|
37
|
+
/**
|
|
38
|
+
* Narrower than `typeof fetch` on purpose — every call site here only ever
|
|
39
|
+
* passes a string URL, never a `Request`/`URL` object, which is also
|
|
40
|
+
* exactly the shape EmDash's own `ctx.http.fetch` (a capability-gated
|
|
41
|
+
* external-fetch helper) exposes. Typing this as the wider `typeof fetch`
|
|
42
|
+
* would make passing `ctx.http.fetch` a type error for no real benefit.
|
|
43
|
+
*/
|
|
44
|
+
fetchImpl?: ((url: string, init?: RequestInit) => Promise<Response>) | undefined;
|
|
45
|
+
}
|
|
46
|
+
interface CommitFileResult {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
status?: number;
|
|
49
|
+
/** The new commit's SHA, present iff `ok`. */
|
|
50
|
+
commitSha?: string;
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create or update `path` on `branch` with `content`, in one commit.
|
|
55
|
+
*
|
|
56
|
+
* Reads the file's current `sha` first (required by the Contents API's
|
|
57
|
+
* update call, omitted for a brand-new file) — a plain GET, not a second
|
|
58
|
+
* round-trip a caller has to orchestrate. Never throws: a network failure, a
|
|
59
|
+
* non-2xx response and a malformed body all come back as `{ ok: false,
|
|
60
|
+
* message }`, matching every other executor in this toolchain.
|
|
61
|
+
*/
|
|
62
|
+
export declare function commitFile(opts: CommitFileOptions): Promise<CommitFileResult>;
|
|
63
|
+
//#endregion
|
|
64
|
+
export type { CommitFileOptions, CommitFileResult };
|
|
65
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/git/github.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;UAwBiB;;EAEf;EACA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;EAQA,cAAc,aAAa,OAAO,gBAAgB,QAAQ;;UAG3C;EACf;EACA;;EAEA;EACA;;;;;;;;;;;wBA6BoB,WAAW,MAAM,oBAAoB,QAAQ"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/git/github.ts
|
|
2
|
+
/**
|
|
3
|
+
* GitHub's Contents API — `commitFile()` creates or updates a single file with
|
|
4
|
+
* one commit, called from the EmDash design-system-editor plugin route (see
|
|
5
|
+
* AGENTS.md's "EmDash plugin as the design-system editing surface" section)
|
|
6
|
+
* to auto-sync a client's admin edit back into the git-tracked `site.json`.
|
|
7
|
+
*
|
|
8
|
+
* `fetch`-only, no `gh` CLI — a deployed Cloudflare Worker has neither a
|
|
9
|
+
* filesystem nor `child_process`, so this can't shell out the way
|
|
10
|
+
* `packages/utils/src/hosting/github.ts` does for a local `vitops setup repo`
|
|
11
|
+
* run. Same "injected `fetchImpl`, structured result, never throws" shape as
|
|
12
|
+
* `onboarding/cloudflare.ts` and `security/turnstile.ts` — a Worker route
|
|
13
|
+
* calling this has already decided a commit failure must degrade (the
|
|
14
|
+
* immediate override still applies), never fail the request.
|
|
15
|
+
*
|
|
16
|
+
* The token is a fine-grained PAT scoped to one repository with `Contents:
|
|
17
|
+
* Read and write` only — an env var (`VITOPS_GITHUB_TOKEN`), never config,
|
|
18
|
+
* the same rule every other credential in this toolchain follows. Branch
|
|
19
|
+
* protection on `main` (provisioned by `vitops setup repo`) is the real
|
|
20
|
+
* backstop against this token being used for anything beyond the integration
|
|
21
|
+
* branch it's meant to commit to, regardless of what it nominally permits.
|
|
22
|
+
*/
|
|
23
|
+
const API = "https://api.github.com";
|
|
24
|
+
/** UTF-8-safe base64 — `btoa` alone only handles Latin1. Portable to both Node and a Worker. */
|
|
25
|
+
function toBase64(str) {
|
|
26
|
+
const bytes = new TextEncoder().encode(str);
|
|
27
|
+
let binary = "";
|
|
28
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
29
|
+
return btoa(binary);
|
|
30
|
+
}
|
|
31
|
+
async function describeError(res) {
|
|
32
|
+
try {
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
return body.message ? `${res.status} ${body.message}` : `HTTP ${res.status}`;
|
|
35
|
+
} catch {
|
|
36
|
+
return `HTTP ${res.status}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create or update `path` on `branch` with `content`, in one commit.
|
|
41
|
+
*
|
|
42
|
+
* Reads the file's current `sha` first (required by the Contents API's
|
|
43
|
+
* update call, omitted for a brand-new file) — a plain GET, not a second
|
|
44
|
+
* round-trip a caller has to orchestrate. Never throws: a network failure, a
|
|
45
|
+
* non-2xx response and a malformed body all come back as `{ ok: false,
|
|
46
|
+
* message }`, matching every other executor in this toolchain.
|
|
47
|
+
*/
|
|
48
|
+
async function commitFile(opts) {
|
|
49
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
50
|
+
const headers = {
|
|
51
|
+
authorization: `Bearer ${opts.token}`,
|
|
52
|
+
accept: "application/vnd.github+json",
|
|
53
|
+
"x-github-api-version": "2022-11-28",
|
|
54
|
+
"user-agent": "@getvitops/utils-git"
|
|
55
|
+
};
|
|
56
|
+
const url = `${API}/repos/${opts.owner}/${opts.repo}/contents/${opts.path}`;
|
|
57
|
+
let sha;
|
|
58
|
+
try {
|
|
59
|
+
const getRes = await fetchImpl(`${url}?ref=${encodeURIComponent(opts.branch)}`, { headers });
|
|
60
|
+
if (getRes.ok) sha = (await getRes.json()).sha;
|
|
61
|
+
else if (getRes.status !== 404) return {
|
|
62
|
+
ok: false,
|
|
63
|
+
status: getRes.status,
|
|
64
|
+
message: await describeError(getRes)
|
|
65
|
+
};
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
message: `network-error: ${e.message}`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const putRes = await fetchImpl(url, {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
headers: {
|
|
76
|
+
...headers,
|
|
77
|
+
"content-type": "application/json"
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
message: opts.message,
|
|
81
|
+
content: toBase64(opts.content),
|
|
82
|
+
branch: opts.branch,
|
|
83
|
+
...sha ? { sha } : {}
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
if (!putRes.ok) return {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: putRes.status,
|
|
89
|
+
message: await describeError(putRes)
|
|
90
|
+
};
|
|
91
|
+
const body = await putRes.json();
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
status: putRes.status,
|
|
95
|
+
...body.commit?.sha ? { commitSha: body.commit.sha } : {}
|
|
96
|
+
};
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
message: `network-error: ${e.message}`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
//#endregion
|
|
106
|
+
export { commitFile };
|
|
107
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/git/github.ts"],"sourcesContent":["/**\n * GitHub's Contents API — `commitFile()` creates or updates a single file with\n * one commit, called from the EmDash design-system-editor plugin route (see\n * AGENTS.md's \"EmDash plugin as the design-system editing surface\" section)\n * to auto-sync a client's admin edit back into the git-tracked `site.json`.\n *\n * `fetch`-only, no `gh` CLI — a deployed Cloudflare Worker has neither a\n * filesystem nor `child_process`, so this can't shell out the way\n * `packages/utils/src/hosting/github.ts` does for a local `vitops setup repo`\n * run. Same \"injected `fetchImpl`, structured result, never throws\" shape as\n * `onboarding/cloudflare.ts` and `security/turnstile.ts` — a Worker route\n * calling this has already decided a commit failure must degrade (the\n * immediate override still applies), never fail the request.\n *\n * The token is a fine-grained PAT scoped to one repository with `Contents:\n * Read and write` only — an env var (`VITOPS_GITHUB_TOKEN`), never config,\n * the same rule every other credential in this toolchain follows. Branch\n * protection on `main` (provisioned by `vitops setup repo`) is the real\n * backstop against this token being used for anything beyond the integration\n * branch it's meant to commit to, regardless of what it nominally permits.\n */\n\nconst API = 'https://api.github.com';\n\nexport interface CommitFileOptions {\n /** Repository owner (user or org). */\n owner: string;\n repo: string;\n /** Branch to commit to — never `main`/production directly, see module docblock. */\n branch: string;\n /** File path within the repo, e.g. `\"site.json\"`. */\n path: string;\n /** The full new file content (already merged — this call replaces it whole). */\n content: string;\n /** Commit message. */\n message: string;\n /** Fine-grained PAT, `Contents: Read and write` on this repo only. */\n token: string;\n /**\n * Narrower than `typeof fetch` on purpose — every call site here only ever\n * passes a string URL, never a `Request`/`URL` object, which is also\n * exactly the shape EmDash's own `ctx.http.fetch` (a capability-gated\n * external-fetch helper) exposes. Typing this as the wider `typeof fetch`\n * would make passing `ctx.http.fetch` a type error for no real benefit.\n */\n fetchImpl?: ((url: string, init?: RequestInit) => Promise<Response>) | undefined;\n}\n\nexport interface CommitFileResult {\n ok: boolean;\n status?: number;\n /** The new commit's SHA, present iff `ok`. */\n commitSha?: string;\n message?: string;\n}\n\n/** UTF-8-safe base64 — `btoa` alone only handles Latin1. Portable to both Node and a Worker. */\nfunction toBase64(str: string): string {\n const bytes = new TextEncoder().encode(str);\n let binary = '';\n for (const b of bytes) binary += String.fromCharCode(b);\n return btoa(binary);\n}\n\nasync function describeError(res: Response): Promise<string> {\n try {\n const body = (await res.json()) as { message?: string };\n return body.message ? `${res.status} ${body.message}` : `HTTP ${res.status}`;\n } catch {\n return `HTTP ${res.status}`;\n }\n}\n\n/**\n * Create or update `path` on `branch` with `content`, in one commit.\n *\n * Reads the file's current `sha` first (required by the Contents API's\n * update call, omitted for a brand-new file) — a plain GET, not a second\n * round-trip a caller has to orchestrate. Never throws: a network failure, a\n * non-2xx response and a malformed body all come back as `{ ok: false,\n * message }`, matching every other executor in this toolchain.\n */\nexport async function commitFile(opts: CommitFileOptions): Promise<CommitFileResult> {\n const fetchImpl = opts.fetchImpl ?? fetch;\n const headers = {\n authorization: `Bearer ${opts.token}`,\n accept: 'application/vnd.github+json',\n 'x-github-api-version': '2022-11-28',\n 'user-agent': '@getvitops/utils-git',\n };\n const url = `${API}/repos/${opts.owner}/${opts.repo}/contents/${opts.path}`;\n\n let sha: string | undefined;\n try {\n const getRes = await fetchImpl(`${url}?ref=${encodeURIComponent(opts.branch)}`, { headers });\n if (getRes.ok) {\n sha = ((await getRes.json()) as { sha?: string }).sha;\n } else if (getRes.status !== 404) {\n return { ok: false, status: getRes.status, message: await describeError(getRes) };\n }\n } catch (e) {\n return { ok: false, message: `network-error: ${(e as Error).message}` };\n }\n\n try {\n const putRes = await fetchImpl(url, {\n method: 'PUT',\n headers: { ...headers, 'content-type': 'application/json' },\n body: JSON.stringify({\n message: opts.message,\n content: toBase64(opts.content),\n branch: opts.branch,\n ...(sha ? { sha } : {}),\n }),\n });\n if (!putRes.ok)\n return { ok: false, status: putRes.status, message: await describeError(putRes) };\n const body = (await putRes.json()) as { commit?: { sha?: string } };\n return {\n ok: true,\n status: putRes.status,\n ...(body.commit?.sha ? { commitSha: body.commit.sha } : {}),\n };\n } catch (e) {\n return { ok: false, message: `network-error: ${(e as Error).message}` };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,MAAM;;AAmCZ,SAAS,SAAS,KAAqB;CACrC,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG;CAC1C,IAAI,SAAS;CACb,KAAK,MAAM,KAAK,OAAO,UAAU,OAAO,aAAa,CAAC;CACtD,OAAO,KAAK,MAAM;AACpB;AAEA,eAAe,cAAc,KAAgC;CAC3D,IAAI;EACF,MAAM,OAAQ,MAAM,IAAI,KAAK;EAC7B,OAAO,KAAK,UAAU,GAAG,IAAI,OAAO,GAAG,KAAK,YAAY,QAAQ,IAAI;CACtE,QAAQ;EACN,OAAO,QAAQ,IAAI;CACrB;AACF;;;;;;;;;;AAWA,eAAsB,WAAW,MAAoD;CACnF,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,UAAU;EACd,eAAe,UAAU,KAAK;EAC9B,QAAQ;EACR,wBAAwB;EACxB,cAAc;CAChB;CACA,MAAM,MAAM,GAAG,IAAI,SAAS,KAAK,MAAM,GAAG,KAAK,KAAK,YAAY,KAAK;CAErE,IAAI;CACJ,IAAI;EACF,MAAM,SAAS,MAAM,UAAU,GAAG,IAAI,OAAO,mBAAmB,KAAK,MAAM,KAAK,EAAE,QAAQ,CAAC;EAC3F,IAAI,OAAO,IACT,OAAQ,MAAM,OAAO,KAAK,EAAC,CAAuB;OAC7C,IAAI,OAAO,WAAW,KAC3B,OAAO;GAAE,IAAI;GAAO,QAAQ,OAAO;GAAQ,SAAS,MAAM,cAAc,MAAM;EAAE;CAEpF,SAAS,GAAG;EACV,OAAO;GAAE,IAAI;GAAO,SAAS,kBAAmB,EAAY;EAAU;CACxE;CAEA,IAAI;EACF,MAAM,SAAS,MAAM,UAAU,KAAK;GAClC,QAAQ;GACR,SAAS;IAAE,GAAG;IAAS,gBAAgB;GAAmB;GAC1D,MAAM,KAAK,UAAU;IACnB,SAAS,KAAK;IACd,SAAS,SAAS,KAAK,OAAO;IAC9B,QAAQ,KAAK;IACb,GAAI,MAAM,EAAE,IAAI,IAAI,CAAC;GACvB,CAAC;EACH,CAAC;EACD,IAAI,CAAC,OAAO,IACV,OAAO;GAAE,IAAI;GAAO,QAAQ,OAAO;GAAQ,SAAS,MAAM,cAAc,MAAM;EAAE;EAClF,MAAM,OAAQ,MAAM,OAAO,KAAK;EAChC,OAAO;GACL,IAAI;GACJ,QAAQ,OAAO;GACf,GAAI,KAAK,QAAQ,MAAM,EAAE,WAAW,KAAK,OAAO,IAAI,IAAI,CAAC;EAC3D;CACF,SAAS,GAAG;EACV,OAAO;GAAE,IAAI;GAAO,SAAS,kBAAmB,EAAY;EAAU;CACxE;AACF"}
|