@getvitops/utils 4.0.0 → 5.0.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.
@@ -0,0 +1,380 @@
1
+ import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
2
+
3
+ //#region src/ads/providers.ts
4
+ /** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */
5
+ const gtagBootstrap = (id) => `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${id}');`;
6
+ /** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */
7
+ const queueStub = (global, methods) => `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};window.${global}.methods=${methods};`;
8
+ const AD_PLATFORMS = {
9
+ google: {
10
+ name: "Google Ads",
11
+ operatorCountry: "the United States",
12
+ clickIdParams: [
13
+ "gclid",
14
+ "gbraid",
15
+ "wbraid"
16
+ ],
17
+ consoleUrl: "https://ads.google.com/",
18
+ verification: {
19
+ method: "none",
20
+ reason: "Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)"
21
+ },
22
+ tag: {
23
+ needs: "pixelId",
24
+ src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,
25
+ bootstrap: gtagBootstrap,
26
+ cookies: [
27
+ "_gcl_au",
28
+ "_gcl_aw",
29
+ "_gcl_dc",
30
+ "_gac_*"
31
+ ],
32
+ privacyUrl: "https://policies.google.com/privacy",
33
+ optOut: "https://adssettings.google.com/"
34
+ },
35
+ manualSteps: (e) => [...e.conversionLabel ? [] : ["create a conversion action in Google Ads and record its label as `conversionLabel`"], "link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions"]
36
+ },
37
+ meta: {
38
+ name: "Meta",
39
+ operatorCountry: "the United States",
40
+ clickIdParams: ["fbclid"],
41
+ consoleUrl: "https://business.facebook.com/settings/owned-domains",
42
+ verification: {
43
+ method: "dns-txt",
44
+ txtPrefix: "facebook-domain-verification=",
45
+ where: "Business settings → Brand safety → Domains → Add → DNS verification"
46
+ },
47
+ tag: {
48
+ needs: "pixelId",
49
+ src: () => "https://connect.facebook.net/en_US/fbevents.js",
50
+ bootstrap: (id) => `${queueStub("fbq", `['init','track','trackCustom','consent']`)}fbq('init','${id}');fbq('track','PageView');`,
51
+ cookies: ["_fbp", "_fbc"],
52
+ privacyUrl: "https://www.facebook.com/privacy/policy/",
53
+ optOut: "https://www.facebook.com/adpreferences/ad_settings"
54
+ },
55
+ manualSteps: () => ["click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called"]
56
+ },
57
+ linkedin: {
58
+ name: "LinkedIn",
59
+ operatorCountry: "the United States",
60
+ clickIdParams: ["li_fat_id"],
61
+ consoleUrl: "https://www.linkedin.com/campaignmanager/",
62
+ verification: {
63
+ method: "none",
64
+ reason: "LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)"
65
+ },
66
+ tag: {
67
+ needs: "accountId",
68
+ src: () => "https://snap.licdn.com/li.lms-analytics/insight.min.js",
69
+ bootstrap: (id) => `window._linkedin_partner_id='${id}';window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];window._linkedin_data_partner_ids.push('${id}');`,
70
+ cookies: [
71
+ "li_sugr",
72
+ "li_gc",
73
+ "bcookie",
74
+ "lidc",
75
+ "UserMatchHistory"
76
+ ],
77
+ privacyUrl: "https://www.linkedin.com/legal/privacy-policy",
78
+ optOut: "https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out"
79
+ },
80
+ manualSteps: () => ["add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions"]
81
+ },
82
+ reddit: {
83
+ name: "Reddit",
84
+ operatorCountry: "the United States",
85
+ clickIdParams: ["rdt_cid"],
86
+ consoleUrl: "https://ads.reddit.com/",
87
+ verification: {
88
+ method: "none",
89
+ reason: "Reddit Ads has no domain verification — the Reddit Pixel is the link."
90
+ },
91
+ tag: {
92
+ needs: "pixelId",
93
+ src: () => "https://www.redditstatic.com/ads/pixel.js",
94
+ bootstrap: (id) => `${queueStub("rdt", `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,
95
+ cookies: ["_rdt_uuid"],
96
+ privacyUrl: "https://www.reddit.com/policies/privacy-policy"
97
+ },
98
+ manualSteps: () => []
99
+ },
100
+ tiktok: {
101
+ name: "TikTok",
102
+ operatorCountry: "Singapore",
103
+ clickIdParams: ["ttclid"],
104
+ consoleUrl: "https://ads.tiktok.com/",
105
+ verification: {
106
+ method: "dns-txt",
107
+ txtPrefix: "tiktok-developers-site-verification=",
108
+ where: "Ads Manager → Assets → Events → Web events → Verify domain → DNS"
109
+ },
110
+ tag: {
111
+ needs: "pixelId",
112
+ src: (id) => `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,
113
+ bootstrap: (id) => `window.TiktokAnalyticsObject='ttq';${queueStub("ttq", `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}ttq('init','${id}');ttq('page');`,
114
+ cookies: ["_ttp"],
115
+ privacyUrl: "https://www.tiktok.com/legal/privacy-policy"
116
+ },
117
+ manualSteps: () => []
118
+ },
119
+ microsoft: {
120
+ name: "Microsoft Ads",
121
+ operatorCountry: "the United States",
122
+ clickIdParams: ["msclkid"],
123
+ consoleUrl: "https://ads.microsoft.com/",
124
+ verification: {
125
+ method: "none",
126
+ reason: "Microsoft Ads has no domain verification — the UET tag is the link."
127
+ },
128
+ tag: {
129
+ needs: "pixelId",
130
+ bootstrap: (id) => `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,
131
+ cookies: [
132
+ "_uetsid",
133
+ "_uetvid",
134
+ "MUID"
135
+ ],
136
+ privacyUrl: "https://privacy.microsoft.com/privacystatement",
137
+ optOut: "https://account.microsoft.com/privacy/ad-settings"
138
+ },
139
+ manualSteps: () => []
140
+ },
141
+ pinterest: {
142
+ name: "Pinterest",
143
+ operatorCountry: "the United States",
144
+ clickIdParams: ["epik"],
145
+ consoleUrl: "https://ads.pinterest.com/",
146
+ verification: {
147
+ method: "dns-txt",
148
+ txtPrefix: "pinterest-site-verification=",
149
+ where: "Business hub → Claim → Websites → Claim → Add TXT record"
150
+ },
151
+ tag: {
152
+ needs: "pixelId",
153
+ bootstrap: (id) => `${queueStub("pintrk", `['load','page','track']`)}!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();pintrk('load','${id}');pintrk('page');`,
154
+ cookies: [
155
+ "_pinterest_ct_ua",
156
+ "_pin_unauth",
157
+ "_epik"
158
+ ],
159
+ privacyUrl: "https://policy.pinterest.com/privacy-policy"
160
+ },
161
+ manualSteps: () => ["confirm the claim in Business hub → Claim once the TXT record has propagated"]
162
+ },
163
+ snapchat: {
164
+ name: "Snapchat",
165
+ operatorCountry: "the United States",
166
+ clickIdParams: ["ScCid"],
167
+ consoleUrl: "https://ads.snapchat.com/",
168
+ verification: {
169
+ method: "dns-txt",
170
+ txtPrefix: "snap-domain-verification=",
171
+ where: "Business Manager → Public Profiles / Domains → Verify domain → DNS"
172
+ },
173
+ tag: {
174
+ needs: "pixelId",
175
+ src: () => "https://sc-static.net/scevent.min.js",
176
+ bootstrap: (id) => `${queueStub("snaptr", `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,
177
+ cookies: [
178
+ "_scid",
179
+ "_scid_r",
180
+ "sc_at"
181
+ ],
182
+ privacyUrl: "https://snap.com/privacy/privacy-policy"
183
+ },
184
+ manualSteps: () => []
185
+ }
186
+ };
187
+ /** Every provider, in table order — the one place iteration order is decided. */
188
+ const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS);
189
+ /** Whether a string names a known provider (for CLI arg validation). */
190
+ const isAdProvider = (value) => value in AD_PLATFORMS;
191
+ /**
192
+ * The DNS TXT record content for a token.
193
+ *
194
+ * A value already containing `=` is taken as the whole record and left alone. That
195
+ * is the same escape hatch an icon name containing `:` gets: a platform can change
196
+ * its prefix spelling without waiting on a toolchain release, and an operator who
197
+ * pasted the full record from the UI gets what they meant rather than a
198
+ * double-prefixed record that verifies against nothing.
199
+ */
200
+ function txtRecord(provider, token) {
201
+ const trimmed = token.trim();
202
+ if (trimmed.includes("=")) return trimmed;
203
+ return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ""}${trimmed}`;
204
+ }
205
+ /** The tag id for an entry, or undefined when the field it needs is unset. */
206
+ function tagId(entry) {
207
+ return entry[AD_PLATFORMS[entry.provider].tag.needs];
208
+ }
209
+ /** The consent category gating an entry's tag. `marketing` unless stated otherwise. */
210
+ const categoryOf = (entry) => entry.category ?? "marketing";
211
+ const escapeAttr = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
212
+ /**
213
+ * One provider's tag, rendered as a **consent-gated, inert** `<script>`.
214
+ *
215
+ * `type="text/plain"` with the URL on `data-src` is what makes the gate real: the
216
+ * browser neither parses the body nor fetches the library, so an undecided
217
+ * visitor's page issues no third-party request. Never give one of these a live
218
+ * `src` — a gate that instead asks a loaded tracker not to track is a promise.
219
+ *
220
+ * Returns null when the entry has no tag id, so a caller can report the gap rather
221
+ * than emit a snippet that initialises `undefined`.
222
+ */
223
+ function renderTag(entry, strategy = "idle") {
224
+ const platform = AD_PLATFORMS[entry.provider];
225
+ const id = tagId(entry);
226
+ if (!id) return null;
227
+ const attrs = [
228
+ "type=\"text/plain\"",
229
+ "data-vitops-tag",
230
+ `data-consent="${categoryOf(entry)}"`,
231
+ `data-strategy="${strategy}"`,
232
+ ...platform.tag.src ? [`data-src="${escapeAttr(platform.tag.src(id))}"`] : [],
233
+ `data-consent-cookies="${escapeAttr(platform.tag.cookies.join(","))}"`
234
+ ];
235
+ return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\n<script ${attrs.join(" ")}>${platform.tag.bootstrap(id, entry)}<\/script>`;
236
+ }
237
+
238
+ //#endregion
239
+ //#region src/ads/plan.ts
240
+ /**
241
+ * Every decision `vitops ads setup` makes, as one pure function.
242
+ *
243
+ * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The
244
+ * executor gathers each domain's live DNS state and applies what it is told; this
245
+ * module decides, from that state, what is left to do. The same split
246
+ * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*
247
+ * account of a run, and `--check` is an honest drift report.
248
+ *
249
+ * Idempotency lives here — a step whose desired state already holds resolves to
250
+ * `skip`, so a re-run of a linked property is all skips by construction rather than
251
+ * by the executor checking twice.
252
+ *
253
+ * One thing this planner adds over the onboarding one: a step that cannot proceed
254
+ * for want of a **config value** resolves to `blocked` carrying the field name in
255
+ * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the
256
+ * missing token and re-plan, while the planner itself stays pure and knows nothing
257
+ * about a terminal. A blocked step is drift, never a skip — the first run of a
258
+ * property whose token has not been fetched yet must not read as "already linked".
259
+ */
260
+ /**
261
+ * Decide one property's steps.
262
+ *
263
+ * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:
264
+ * our record is present (skip), a *different* record from the same platform is
265
+ * present (create, and say so — a token rotation leaves the old record in place,
266
+ * since this command never edits or deletes DNS), or no token is configured yet
267
+ * (blocked, naming the field).
268
+ */
269
+ function planProperty(setup, state) {
270
+ const platform = AD_PLATFORMS[setup.provider];
271
+ const reminders = [...platform.manualSteps(setup)];
272
+ let txt;
273
+ let txtContent;
274
+ if (platform.verification.method === "none") txt = {
275
+ action: "skip",
276
+ detail: platform.verification.reason ?? "no domain verification"
277
+ };
278
+ else if (!setup.domainVerification) txt = {
279
+ action: "blocked",
280
+ detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,
281
+ needs: "domainVerification"
282
+ };
283
+ else {
284
+ const desired = txtRecord(setup.provider, setup.domainVerification);
285
+ const prefix = platform.verification.txtPrefix;
286
+ const stale = prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);
287
+ txt = state.txtContents.includes(desired) ? {
288
+ action: "skip",
289
+ detail: "verification TXT already present"
290
+ } : {
291
+ action: "create",
292
+ detail: stale ? "create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)" : "create verification TXT"
293
+ };
294
+ if (txt.action === "create") txtContent = desired;
295
+ }
296
+ const id = tagId(setup);
297
+ const tag = id ? {
298
+ action: "skip",
299
+ detail: `tag ready (${platform.tag.needs} ${id})`
300
+ } : {
301
+ action: "blocked",
302
+ detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,
303
+ needs: platform.tag.needs
304
+ };
305
+ return {
306
+ provider: setup.provider,
307
+ name: platform.name,
308
+ ...setup.domain ? { domain: setup.domain } : {},
309
+ txt,
310
+ tag,
311
+ ...txtContent ? { txtContent } : {},
312
+ reminders
313
+ };
314
+ }
315
+ /** Plan every configured property. */
316
+ function plan(config, states) {
317
+ const notes = [];
318
+ return {
319
+ properties: config.properties.map((setup) => {
320
+ const key = setup.domain ?? "";
321
+ const state = states.get(key);
322
+ if (!state && AD_PLATFORMS[setup.provider].verification.method === "dns-txt") notes.push(`${setup.provider}: no observed DNS state for ${key || "(no domain)"}`);
323
+ return planProperty(setup, state ?? { txtContents: [] });
324
+ }),
325
+ notes
326
+ };
327
+ }
328
+ /** Any step that isn't a skip — i.e. the live state or the config has drifted. */
329
+ function hasDrift(p) {
330
+ return p.properties.some((x) => x.txt.action !== "skip" || x.tag.action !== "skip");
331
+ }
332
+ /** Every field a run still needs, per provider — what the CLI asks for. */
333
+ function missingFields(p) {
334
+ const out = [];
335
+ for (const x of p.properties) for (const step of [x.txt, x.tag]) if (step.action === "blocked" && step.needs) out.push({
336
+ provider: x.provider,
337
+ needs: step.needs
338
+ });
339
+ return out;
340
+ }
341
+ /** Render a plan as the block `--dry` and `--check` print. */
342
+ function formatPlan(p) {
343
+ const glyph = (s) => s.action === "skip" ? "·" : s.action === "blocked" ? "?" : "+";
344
+ const lines = [];
345
+ for (const x of p.properties) {
346
+ lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ""}`);
347
+ lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);
348
+ lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);
349
+ for (const r of x.reminders) lines.push(` ! ${r}`);
350
+ }
351
+ for (const n of p.notes) lines.push(`! ${n}`);
352
+ return lines.join("\n");
353
+ }
354
+ const pad = (s, n) => s.padEnd(n);
355
+ /** Render the end-of-run summary: one row per property, plus any reminders. */
356
+ function formatSummary(results) {
357
+ const headers = [
358
+ "PROVIDER",
359
+ "DOMAIN",
360
+ "VERIFY",
361
+ "TAG"
362
+ ];
363
+ const rows = results.map((r) => [
364
+ r.provider,
365
+ r.domain,
366
+ r.txt,
367
+ r.tag
368
+ ]);
369
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
370
+ const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
371
+ const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
372
+ for (const row of rows) out.push(line(row));
373
+ const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));
374
+ if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
375
+ return out.join("\n");
376
+ }
377
+
378
+ //#endregion
379
+ export { AD_PLATFORMS, AD_PROVIDER_KEYS, categoryOf, createApexTxt, findZoneId, formatPlan, formatSummary, hasDrift, isAdProvider, listApexTxt, missingFields, plan, planProperty, renderTag, tagId, txtRecord };
380
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/ads/providers.ts","../../src/ads/plan.ts"],"sourcesContent":["/**\n * What each ad platform actually lets a build tool do — the table every `vitops ads`\n * subcommand reads.\n *\n * Written the way the indexing module was written: start from what the platforms\n * accept, because it is much narrower than it looks, and record the *absence* of a\n * path as a fact rather than leaving it as a gap.\n *\n * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,\n * Snapchat). That record is the one thing here that can be created for you.\n * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,\n * Microsoft Ads). Linking is the tag and the account id, nothing else. Those\n * resolve to a skip *plus* a named reason — a silent omission would read as\n * \"already done\".\n * - **No platform Marketing API is called.** Meta's Business Management\n * `verify_domain` needs a system-user token; the Google Ads API needs an\n * approved developer token and puts the consumer's own account on the line.\n * That is the same reasoning that keeps the Google Indexing API unwired: a\n * toolchain other people install must not ship a documented path to a terms\n * violation. The final \"Verify\" click stays a reminder.\n *\n * The verification token is **not a secret** — it is published in DNS, and the\n * platform fetching it back is the ownership proof, exactly like the IndexNow key.\n * So it lives in the config while credentials stay in the environment.\n *\n * Each tag is the platform's documented bootstrap reduced to its queue stub, with\n * the remote library on `data-src` for the consent gate to attach after the\n * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body\n * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a\n * snippet that creates its own `<script>`; those declare no `src`, and their body\n * stays inert until activation, so an undecided visitor still issues no request.\n */\nimport type { AdConsentCategory, AdProvider, AdPropertySetup } from './types.ts';\n\n/** How a platform proves you own the domain. */\nexport interface AdVerificationSpec {\n method: 'dns-txt' | 'none';\n /**\n * Prefix prepended to a bare token to form the record content. Owned here so a\n * config holds the token the platform shows, not a hand-assembled record — but\n * see `txtRecord`: a value already containing `=` passes through whole, which is\n * the escape hatch when a platform changes its spelling.\n */\n txtPrefix?: string;\n /** Where in the platform UI the token is found. Printed when asking for it. */\n where?: string;\n /** Why there is nothing to automate. Required when `method` is `none`. */\n reason?: string;\n}\n\n/** The tag, and which config field carries its id. */\nexport interface AdTagSpec {\n /** Which field the tag id comes from — and therefore what to ask for when absent. */\n needs: 'pixelId' | 'accountId';\n /** The remote library. Absent when the platform's own bootstrap loads itself. */\n src?: (id: string) => string;\n /** The inline bootstrap, which the gate runs before attaching `src`. */\n bootstrap: (id: string, entry: AdPropertySetup) => string;\n /**\n * Cookies the tag sets, for `data-consent-cookies` (what a revoke clears) and\n * for the generated cookie notice. A `*` suffix is a prefix match.\n */\n cookies: string[];\n /** The provider's own privacy policy, for the cookie notice. */\n privacyUrl: string;\n /** A visitor-facing opt-out, where the platform offers one. */\n optOut?: string;\n}\n\nexport interface AdPlatform {\n /** The platform as a reader would recognise it. */\n name: string;\n /**\n * The jurisdiction that can compel this platform to produce what it receives —\n * where the contracting entity is established. Feeds the generated privacy\n * policy's cross-border transfer disclosure, which is a claim about foreign\n * *access*, not foreign storage (see `Processor.operatorCountry` in the\n * generator's `legal/providers.ts`). A consumer whose contracting entity differs\n * declares their own processor entry instead.\n */\n operatorCountry: string;\n /** Click-ID parameters this platform stamps on an ad landing URL. */\n clickIdParams: string[];\n /** Where the operator manages the property. */\n consoleUrl: string;\n verification: AdVerificationSpec;\n tag: AdTagSpec;\n /** Follow-ups no API covers, given this entry. */\n manualSteps: (entry: AdPropertySetup) => string[];\n}\n\n/** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */\nconst gtagBootstrap = (id: string): string =>\n `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}` +\n `gtag('js',new Date());gtag('config','${id}');`;\n\n/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */\nconst queueStub = (global: string, methods: string): string =>\n `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};` +\n `window.${global}.methods=${methods};`;\n\nexport const AD_PLATFORMS: Record<AdProvider, AdPlatform> = {\n google: {\n name: 'Google Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['gclid', 'gbraid', 'wbraid'],\n consoleUrl: 'https://ads.google.com/',\n verification: {\n method: 'none',\n reason:\n 'Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)',\n },\n tag: {\n // The customer id (`123-456-7890`) is not the tag id: the conversion tag is\n // `AW-…`, which is why this asks for `pixelId` rather than reusing accountId.\n needs: 'pixelId',\n src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,\n bootstrap: gtagBootstrap,\n cookies: ['_gcl_au', '_gcl_aw', '_gcl_dc', '_gac_*'],\n privacyUrl: 'https://policies.google.com/privacy',\n optOut: 'https://adssettings.google.com/',\n },\n manualSteps: (e) => [\n ...(e.conversionLabel\n ? []\n : ['create a conversion action in Google Ads and record its label as `conversionLabel`']),\n 'link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions',\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n consoleUrl: 'https://business.facebook.com/settings/owned-domains',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'facebook-domain-verification=',\n where: 'Business settings → Brand safety → Domains → Add → DNS verification',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://connect.facebook.net/en_US/fbevents.js',\n bootstrap: (id) =>\n `${queueStub('fbq', `['init','track','trackCustom','consent']`)}` +\n `fbq('init','${id}');fbq('track','PageView');`,\n cookies: ['_fbp', '_fbc'],\n privacyUrl: 'https://www.facebook.com/privacy/policy/',\n optOut: 'https://www.facebook.com/adpreferences/ad_settings',\n },\n manualSteps: () => [\n 'click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called',\n ],\n },\n\n linkedin: {\n name: 'LinkedIn',\n operatorCountry: 'the United States',\n clickIdParams: ['li_fat_id'],\n consoleUrl: 'https://www.linkedin.com/campaignmanager/',\n verification: {\n method: 'none',\n reason:\n 'LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)',\n },\n tag: {\n needs: 'accountId',\n src: () => 'https://snap.licdn.com/li.lms-analytics/insight.min.js',\n bootstrap: (id) =>\n `window._linkedin_partner_id='${id}';` +\n `window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];` +\n `window._linkedin_data_partner_ids.push('${id}');`,\n cookies: ['li_sugr', 'li_gc', 'bcookie', 'lidc', 'UserMatchHistory'],\n privacyUrl: 'https://www.linkedin.com/legal/privacy-policy',\n optOut: 'https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out',\n },\n manualSteps: () => [\n 'add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions',\n ],\n },\n\n reddit: {\n name: 'Reddit',\n operatorCountry: 'the United States',\n clickIdParams: ['rdt_cid'],\n consoleUrl: 'https://ads.reddit.com/',\n verification: {\n method: 'none',\n reason: 'Reddit Ads has no domain verification — the Reddit Pixel is the link.',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://www.redditstatic.com/ads/pixel.js',\n bootstrap: (id) =>\n `${queueStub('rdt', `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,\n cookies: ['_rdt_uuid'],\n privacyUrl: 'https://www.reddit.com/policies/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n tiktok: {\n name: 'TikTok',\n operatorCountry: 'Singapore',\n clickIdParams: ['ttclid'],\n consoleUrl: 'https://ads.tiktok.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'tiktok-developers-site-verification=',\n where: 'Ads Manager → Assets → Events → Web events → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: (id) =>\n `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,\n bootstrap: (id) =>\n `window.TiktokAnalyticsObject='ttq';` +\n `${queueStub('ttq', `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}` +\n `ttq('init','${id}');ttq('page');`,\n cookies: ['_ttp'],\n privacyUrl: 'https://www.tiktok.com/legal/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n microsoft: {\n name: 'Microsoft Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['msclkid'],\n consoleUrl: 'https://ads.microsoft.com/',\n verification: {\n method: 'none',\n reason: 'Microsoft Ads has no domain verification — the UET tag is the link.',\n },\n tag: {\n needs: 'pixelId',\n // No `src`: UET must be constructed *after* bat.js evaluates, so the\n // platform's own snippet attaches an onload handler to a script it creates.\n // Inert until activation all the same — the body is never parsed before then.\n bootstrap: (id) =>\n `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};` +\n `o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,` +\n `n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},` +\n `i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,\n cookies: ['_uetsid', '_uetvid', 'MUID'],\n privacyUrl: 'https://privacy.microsoft.com/privacystatement',\n optOut: 'https://account.microsoft.com/privacy/ad-settings',\n },\n manualSteps: () => [],\n },\n\n pinterest: {\n name: 'Pinterest',\n operatorCountry: 'the United States',\n clickIdParams: ['epik'],\n consoleUrl: 'https://ads.pinterest.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'pinterest-site-verification=',\n where: 'Business hub → Claim → Websites → Claim → Add TXT record',\n },\n tag: {\n needs: 'pixelId',\n // Pinterest's stub creates the script itself once `pintrk('load')` runs.\n bootstrap: (id) =>\n `${queueStub('pintrk', `['load','page','track']`)}` +\n `!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';` +\n `var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();` +\n `pintrk('load','${id}');pintrk('page');`,\n cookies: ['_pinterest_ct_ua', '_pin_unauth', '_epik'],\n privacyUrl: 'https://policy.pinterest.com/privacy-policy',\n },\n manualSteps: () => [\n 'confirm the claim in Business hub → Claim once the TXT record has propagated',\n ],\n },\n\n snapchat: {\n name: 'Snapchat',\n operatorCountry: 'the United States',\n clickIdParams: ['ScCid'],\n consoleUrl: 'https://ads.snapchat.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'snap-domain-verification=',\n where: 'Business Manager → Public Profiles / Domains → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://sc-static.net/scevent.min.js',\n bootstrap: (id) =>\n `${queueStub('snaptr', `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,\n cookies: ['_scid', '_scid_r', 'sc_at'],\n privacyUrl: 'https://snap.com/privacy/privacy-policy',\n },\n manualSteps: () => [],\n },\n};\n\n/** Every provider, in table order — the one place iteration order is decided. */\nexport const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS) as AdProvider[];\n\n/** Whether a string names a known provider (for CLI arg validation). */\nexport const isAdProvider = (value: string): value is AdProvider => value in AD_PLATFORMS;\n\n/**\n * The DNS TXT record content for a token.\n *\n * A value already containing `=` is taken as the whole record and left alone. That\n * is the same escape hatch an icon name containing `:` gets: a platform can change\n * its prefix spelling without waiting on a toolchain release, and an operator who\n * pasted the full record from the UI gets what they meant rather than a\n * double-prefixed record that verifies against nothing.\n */\nexport function txtRecord(provider: AdProvider, token: string): string {\n const trimmed = token.trim();\n if (trimmed.includes('=')) return trimmed;\n return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ''}${trimmed}`;\n}\n\n/** The tag id for an entry, or undefined when the field it needs is unset. */\nexport function tagId(entry: AdPropertySetup): string | undefined {\n const field = AD_PLATFORMS[entry.provider].tag.needs;\n return entry[field];\n}\n\n/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */\nexport const categoryOf = (entry: AdPropertySetup): AdConsentCategory =>\n entry.category ?? 'marketing';\n\nconst escapeAttr = (value: string): string =>\n value.replace(/&/g, '&amp;').replace(/\"/g, '&quot;').replace(/</g, '&lt;');\n\n/**\n * One provider's tag, rendered as a **consent-gated, inert** `<script>`.\n *\n * `type=\"text/plain\"` with the URL on `data-src` is what makes the gate real: the\n * browser neither parses the body nor fetches the library, so an undecided\n * visitor's page issues no third-party request. Never give one of these a live\n * `src` — a gate that instead asks a loaded tracker not to track is a promise.\n *\n * Returns null when the entry has no tag id, so a caller can report the gap rather\n * than emit a snippet that initialises `undefined`.\n */\nexport function renderTag(\n entry: AdPropertySetup,\n strategy: 'idle' | 'async' | 'interaction' = 'idle',\n): string | null {\n const platform = AD_PLATFORMS[entry.provider];\n const id = tagId(entry);\n if (!id) return null;\n const attrs = [\n 'type=\"text/plain\"',\n 'data-vitops-tag',\n `data-consent=\"${categoryOf(entry)}\"`,\n `data-strategy=\"${strategy}\"`,\n ...(platform.tag.src ? [`data-src=\"${escapeAttr(platform.tag.src(id))}\"`] : []),\n `data-consent-cookies=\"${escapeAttr(platform.tag.cookies.join(','))}\"`,\n ];\n return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\\n<script ${attrs.join(' ')}>${platform.tag.bootstrap(id, entry)}</script>`;\n}\n","/**\n * Every decision `vitops ads setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor gathers each domain's live DNS state and applies what it is told; this\n * module decides, from that state, what is left to do. The same split\n * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*\n * account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here — a step whose desired state already holds resolves to\n * `skip`, so a re-run of a linked property is all skips by construction rather than\n * by the executor checking twice.\n *\n * One thing this planner adds over the onboarding one: a step that cannot proceed\n * for want of a **config value** resolves to `blocked` carrying the field name in\n * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the\n * missing token and re-plan, while the planner itself stays pure and knows nothing\n * about a terminal. A blocked step is drift, never a skip — the first run of a\n * property whose token has not been fetched yet must not read as \"already linked\".\n */\nimport { AD_PLATFORMS, tagId, txtRecord } from './providers.ts';\nimport type { AdDomainState, AdPropertySetup, AdsConfig } from './types.ts';\n\n/** A step's decision. `blocked` means the config is missing something. */\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\n/** A config field the operator can supply to unblock a step. */\nexport type MissingField = 'domainVerification' | 'accountId' | 'pixelId';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do, or what is missing. */\n detail: string;\n /** Set only on `blocked`: which config field would unblock it. */\n needs?: MissingField | undefined;\n}\n\n/** The steps for one ad property, decided against its live state. */\nexport interface AdPropertyPlan {\n provider: AdPropertySetup['provider'];\n /** The platform name, so a formatter needn't reach back into the table. */\n name: string;\n /** The domain being verified, when the platform verifies one. */\n domain?: string | undefined;\n /** Ensure the apex verification TXT. */\n txt: StepPlan;\n /** Whether the tag can be emitted (`ads tags`). */\n tag: StepPlan;\n /** The exact TXT content to create, when `txt.action` is `create`. */\n txtContent?: string | undefined;\n /** Manual follow-ups — including *why* a platform has no verification step. */\n reminders: string[];\n}\n\nexport interface AdsPlan {\n properties: AdPropertyPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/**\n * Decide one property's steps.\n *\n * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:\n * our record is present (skip), a *different* record from the same platform is\n * present (create, and say so — a token rotation leaves the old record in place,\n * since this command never edits or deletes DNS), or no token is configured yet\n * (blocked, naming the field).\n */\nexport function planProperty(setup: AdPropertySetup, state: AdDomainState): AdPropertyPlan {\n const platform = AD_PLATFORMS[setup.provider];\n const reminders = [...platform.manualSteps(setup)];\n\n let txt: StepPlan;\n let txtContent: string | undefined;\n\n if (platform.verification.method === 'none') {\n txt = { action: 'skip', detail: platform.verification.reason ?? 'no domain verification' };\n } else if (!setup.domainVerification) {\n txt = {\n action: 'blocked',\n detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,\n needs: 'domainVerification',\n };\n } else {\n const desired = txtRecord(setup.provider, setup.domainVerification);\n const prefix = platform.verification.txtPrefix;\n const stale =\n prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);\n txt = state.txtContents.includes(desired)\n ? { action: 'skip', detail: 'verification TXT already present' }\n : {\n action: 'create',\n detail: stale\n ? 'create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)'\n : 'create verification TXT',\n };\n if (txt.action === 'create') txtContent = desired;\n }\n\n const id = tagId(setup);\n const tag: StepPlan = id\n ? { action: 'skip', detail: `tag ready (${platform.tag.needs} ${id})` }\n : {\n action: 'blocked',\n detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,\n needs: platform.tag.needs,\n };\n\n return {\n provider: setup.provider,\n name: platform.name,\n ...(setup.domain ? { domain: setup.domain } : {}),\n txt,\n tag,\n ...(txtContent ? { txtContent } : {}),\n reminders,\n };\n}\n\n/** Plan every configured property. */\nexport function plan(config: AdsConfig, states: Map<string, AdDomainState>): AdsPlan {\n const notes: string[] = [];\n const properties = config.properties.map((setup) => {\n const key = setup.domain ?? '';\n const state = states.get(key);\n if (!state && AD_PLATFORMS[setup.provider].verification.method === 'dns-txt')\n // Should not happen — the CLI observes every verifiable domain before\n // planning — but a missing observation must not read as \"nothing to do\".\n notes.push(`${setup.provider}: no observed DNS state for ${key || '(no domain)'}`);\n return planProperty(setup, state ?? { txtContents: [] });\n });\n return { properties, notes };\n}\n\n/** Any step that isn't a skip — i.e. the live state or the config has drifted. */\nexport function hasDrift(p: AdsPlan): boolean {\n return p.properties.some((x) => x.txt.action !== 'skip' || x.tag.action !== 'skip');\n}\n\n/** Every field a run still needs, per provider — what the CLI asks for. */\nexport function missingFields(p: AdsPlan): { provider: string; needs: MissingField }[] {\n const out: { provider: string; needs: MissingField }[] = [];\n for (const x of p.properties) {\n for (const step of [x.txt, x.tag]) {\n if (step.action === 'blocked' && step.needs)\n out.push({ provider: x.provider, needs: step.needs });\n }\n }\n return out;\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: AdsPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const x of p.properties) {\n lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ''}`);\n lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);\n lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);\n for (const r of x.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 property's run, for the summary table. */\nexport interface AdPropertyResult {\n provider: string;\n domain: string;\n txt: 'created' | 'present' | 'blocked' | 'failed' | 'n/a';\n tag: 'ready' | 'blocked';\n reminders: string[];\n}\n\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render the end-of-run summary: one row per property, plus any reminders. */\nexport function formatSummary(results: AdPropertyResult[]): string {\n const headers = ['PROVIDER', 'DOMAIN', 'VERIFY', 'TAG'];\n const rows = results.map((r) => [r.provider, r.domain, r.txt, r.tag]);\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));\n if (reminders.length) out.push('', 'Reminders (manual — no API):', ...reminders);\n return out.join('\\n');\n}\n"],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ //#region src/onboarding/cloudflare.d.ts
2
+ /** Find the zone id for a domain (`GET /zones?name=`). */
3
+ declare function findZoneId(token: string, domain: string, fetchImpl?: typeof fetch): Promise<{
4
+ ok: boolean;
5
+ status: number;
6
+ zoneId?: string;
7
+ message?: string;
8
+ }>;
9
+ /** List the apex TXT record contents for a zone. */
10
+ declare function listApexTxt(token: string, zoneId: string, domain: string, fetchImpl?: typeof fetch): Promise<{
11
+ ok: boolean;
12
+ status: number;
13
+ contents: string[];
14
+ message?: string;
15
+ }>;
16
+ /**
17
+ * Create an apex TXT record. **Create only** — never called when one already
18
+ * exists (the planner decides that), and there is no sibling that edits or removes.
19
+ */
20
+ declare function createApexTxt(token: string, zoneId: string, domain: string, content: string, fetchImpl?: typeof fetch): Promise<{
21
+ ok: boolean;
22
+ status: number;
23
+ message?: string;
24
+ }>;
25
+ //#endregion
26
+ export { findZoneId as n, listApexTxt as r, createApexTxt as t };
27
+ //# sourceMappingURL=cloudflare-BiK61Oq4.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloudflare-BiK61Oq4.d.mts","names":[],"sources":["../src/onboarding/cloudflare.ts"],"mappings":";;iBA8BsB,UAAA,CACpB,KAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,MAAA;EAAiB,OAAA;AAAA;;iBAsBrC,WAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,QAAA;EAAoB,OAAA;AAAA;;AA3BI;AAsBlE;;iBAsBsB,aAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA"}
@@ -0,0 +1,92 @@
1
+ //#region src/onboarding/cloudflare.ts
2
+ /**
3
+ * Cloudflare DNS — the one thing this command writes.
4
+ *
5
+ * Scoped to exactly what onboarding needs and nothing more: find a zone by name,
6
+ * read the apex TXT records, and *create* the verification TXT when it is missing.
7
+ * There is deliberately no update and no delete — the command's contract is that it
8
+ * never removes a DNS record, so the destructive verbs are simply absent rather
9
+ * than guarded.
10
+ *
11
+ * The token is a `Zone:DNS:Edit` credential from the environment
12
+ * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which
13
+ * Cloudflare's standard "Edit zone DNS" token template already bundles. As with the
14
+ * indexing executors, `fetchImpl` is injected so the request shape is testable
15
+ * without the network, and every call returns a structured result rather than
16
+ * throwing — the caller decides what a failure means per domain.
17
+ */
18
+ const API = "https://api.cloudflare.com/client/v4";
19
+ const cfError = (status, body) => {
20
+ const first = body.errors?.[0];
21
+ return first ? `${first.code} ${first.message}` : `HTTP ${status}`;
22
+ };
23
+ /** Find the zone id for a domain (`GET /zones?name=`). */
24
+ async function findZoneId(token, domain, fetchImpl = fetch) {
25
+ const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, { headers: { authorization: `Bearer ${token}` } });
26
+ const body = await res.json();
27
+ if (!res.ok || !body.success) return {
28
+ ok: false,
29
+ status: res.status,
30
+ message: cfError(res.status, body)
31
+ };
32
+ const zone = body.result?.[0];
33
+ if (!zone) return {
34
+ ok: false,
35
+ status: res.status,
36
+ message: `no Cloudflare zone named "${domain}" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The "Edit zone DNS" template covers both.)`
37
+ };
38
+ return {
39
+ ok: true,
40
+ status: res.status,
41
+ zoneId: zone.id
42
+ };
43
+ }
44
+ /** List the apex TXT record contents for a zone. */
45
+ async function listApexTxt(token, zoneId, domain, fetchImpl = fetch) {
46
+ const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`, { headers: { authorization: `Bearer ${token}` } });
47
+ const body = await res.json();
48
+ if (!res.ok || !body.success) return {
49
+ ok: false,
50
+ status: res.status,
51
+ contents: [],
52
+ message: cfError(res.status, body)
53
+ };
54
+ const contents = (body.result ?? []).map((r) => r.content.replace(/^"|"$/g, ""));
55
+ return {
56
+ ok: true,
57
+ status: res.status,
58
+ contents
59
+ };
60
+ }
61
+ /**
62
+ * Create an apex TXT record. **Create only** — never called when one already
63
+ * exists (the planner decides that), and there is no sibling that edits or removes.
64
+ */
65
+ async function createApexTxt(token, zoneId, domain, content, fetchImpl = fetch) {
66
+ const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {
67
+ method: "POST",
68
+ headers: {
69
+ authorization: `Bearer ${token}`,
70
+ "content-type": "application/json"
71
+ },
72
+ body: JSON.stringify({
73
+ type: "TXT",
74
+ name: domain,
75
+ content
76
+ })
77
+ });
78
+ const body = await res.json();
79
+ if (!res.ok || !body.success) return {
80
+ ok: false,
81
+ status: res.status,
82
+ message: cfError(res.status, body)
83
+ };
84
+ return {
85
+ ok: true,
86
+ status: res.status
87
+ };
88
+ }
89
+
90
+ //#endregion
91
+ export { findZoneId as n, listApexTxt as r, createApexTxt as t };
92
+ //# sourceMappingURL=cloudflare-CD8vwrgj.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloudflare-CD8vwrgj.mjs","names":[],"sources":["../src/onboarding/cloudflare.ts"],"sourcesContent":["/**\n * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\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\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\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)\n return {\n ok: false,\n status: res.status,\n // Name the scope: this lookup is a GET /zones?name=, which needs\n // `Zone:Read` — a token carrying only `Zone:DNS:Edit` reaches here and\n // reads as \"the zone isn't in this account\", which sends you to the\n // wrong dashboard.\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The \"Edit zone DNS\" template covers both.)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,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,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EAKZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,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,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,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;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC"}
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;EAaA;;AAAe;AAsDjB;;;;;;;;AAAqE;EAtDnE,eAAA;AAAA;;;;;;iBAsDoB,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO;AAAA,UAqGpD,WAAA;EACf,GAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EAIf;EAFA,MAAA;EAII;EAFJ,QAAA;EASc;EAPd,IAAA;AAAA;;;;;iBAOc,YAAA,CAAa,IAAA,GAAM,iBAAA,GAAyB,WAAW;AAAA,UAWtD,kBAAA;EACf,IAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EAHA;EAKA,IAAA;AAAA;;iBAIc,eAAA,CAAgB,IAAA,EAAM,kBAAA,GAAqB,MAAM;;iBAuB3C,oBAAA,CAAqB,GAAA,UAAa,IAAA,EAAM,kBAAA,GAAqB,OAAO"}
1
+ {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;EAaA;;AAAe;AA0EjB;;;;;;;;AAAqE;EA1EnE,eAAA;AAAA;;;;;;iBA0EoB,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO;AAAA,UA4GpD,WAAA;EACf,GAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EAIf;EAFA,MAAA;EAII;EAFJ,QAAA;EASc;EAPd,IAAA;AAAA;;;;;iBAOc,YAAA,CAAa,IAAA,GAAM,iBAAA,GAAyB,WAAW;AAAA,UAWtD,kBAAA;EACf,IAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EAHA;EAKA,IAAA;AAAA;;iBAIc,eAAA,CAAgB,IAAA,EAAM,kBAAA,GAAqB,MAAM;;iBAuB3C,oBAAA,CAAqB,GAAA,UAAa,IAAA,EAAM,kBAAA,GAAqB,OAAO"}
package/dist/favicon.mjs CHANGED
@@ -56,6 +56,25 @@ async function hasTransparency(sharp, source) {
56
56
  }
57
57
  }
58
58
  /**
59
+ * Is the mark dark enough to disappear against a white default?
60
+ *
61
+ * Transparency is not the only way to lose a logo. `apple-touch-icon` insets the
62
+ * mark into a larger canvas and fills the surround with the mask background, so
63
+ * an **opaque** dark logo still gets a white frame — and the transparency-only
64
+ * check said nothing about it. Mean channel luminance over the rasterised
65
+ * source is a coarse signal, which is the right amount for a warning.
66
+ */
67
+ async function isDarkMark(sharp, source) {
68
+ try {
69
+ const { channels } = await sharp(source, { density: 384 }).stats();
70
+ const rgb = channels.slice(0, 3);
71
+ if (!rgb.length) return false;
72
+ return rgb.reduce((sum, c) => sum + c.mean, 0) / rgb.length < 128;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
59
78
  * Generate the standard favicon set from an SVG/PNG source into `outputDir`:
60
79
  * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.
61
80
  * Returns the list of written file paths.
@@ -104,7 +123,11 @@ async function generateFavicons(opts) {
104
123
  if (lowResSource) icoSources.unshift(out("icon-16.png"));
105
124
  await writeFile(out("favicon.ico"), await pngToIco(icoSources));
106
125
  written.push(out("favicon.ico"));
107
- if (opts.backgroundColor == null && await hasTransparency(sharp, source)) console.warn(`favicon: ${source} has transparency, and the maskable outputs (icon-mask.png, apple-touch-icon.png) must be opaque — the OS crops them to its own shape. Defaulting their background to #ffffff; set backgroundColor to choose.`);
126
+ if (opts.backgroundColor == null) {
127
+ const transparent = await hasTransparency(sharp, source);
128
+ const dark = transparent ? false : await isDarkMark(sharp, source);
129
+ if (transparent || dark) console.warn(`favicon: ${source} ${transparent ? "has transparency" : "is a dark mark"}, and the maskable outputs (icon-mask.png, apple-touch-icon.png) are composited onto an opaque background — the OS crops them to its own shape. Defaulting that background to #ffffff; set backgroundColor to choose. (It is not manifest-only: these two files are written whether or not a web manifest is.)`);
130
+ }
108
131
  await sharp(out("icon-512.png")).resize(140, 140, {
109
132
  fit: "contain",
110
133
  background: maskBg