@getvitops/cli 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -4,11 +4,14 @@ import { parseArgs } from "node:util";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, parse, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
7
+ import { JURISDICTION_COUNTRIES, NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, processorsMissingLocation, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
8
8
  import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
9
9
  import { SCOPES, SNAPSHOT_PATH, collectEntries, formatPlan, googleAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
10
10
  import { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan as formatPlan$1, formatSummary, getSite, getVerificationToken, getWebResource, hasDrift, listApexTxt, ownerUnion, plan as plan$1, refreshTokenGrant, siteUrlFor, updateOwners, verifyWebResource } from "@getvitops/utils/onboarding";
11
- import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$2, processMedia } from "@getvitops/utils/media";
11
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS, formatPlan as formatPlan$2, formatSummary as formatSummary$1, hasDrift as hasDrift$1, isAdProvider, missingFields, plan as plan$2, renderTag, tagId } from "@getvitops/utils/ads";
12
+ import { PLATFORM_PARAMS } from "@getvitops/utils/tracking";
13
+ import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$3, processMedia } from "@getvitops/utils/media";
14
+ import { createInterface } from "node:readline/promises";
12
15
 
13
16
  //#region src/agents.ts
14
17
  /**
@@ -126,6 +129,144 @@ function linkSkill(linkPath, targetDir) {
126
129
  }
127
130
  }
128
131
 
132
+ //#endregion
133
+ //#region src/prompt.ts
134
+ /**
135
+ * Asking for the config values `vitops ads setup` cannot derive, and writing the
136
+ * answers back.
137
+ *
138
+ * The realistic first run has no verification token in the config, because the
139
+ * token does not exist until someone opens the platform UI and asks for one. That
140
+ * makes a missing token a **question**, not an error — but only when there is
141
+ * somebody there to answer it, which is why every decision about *whether* to ask
142
+ * is made here and the planner stays pure.
143
+ *
144
+ * The split mirrors the rest of the toolchain: `questionFor` and `patchRaw` are
145
+ * pure and tested; `ask` and `writeConfigPatch` are the I/O, and they decide
146
+ * nothing.
147
+ *
148
+ * Two rules hold the whole file together:
149
+ *
150
+ * - **A prompt must never hang a CI run.** Asking requires a TTY and an explicit
151
+ * absence of `--dry` / `--check` / `--no-prompt`; otherwise the caller gets the
152
+ * same named error it would have got before this file existed.
153
+ * - **A fact about the site goes in the config; a credential stays in the
154
+ * environment.** A verification token is published in DNS — the platform
155
+ * fetching it back is the ownership proof — so persisting it is right, and it is
156
+ * the only thing here that gets written. `CLOUDFLARE_API_TOKEN` may be typed at
157
+ * a prompt and lives only in the process.
158
+ */
159
+ /**
160
+ * What to ask for one gap, and where the operator finds it.
161
+ *
162
+ * The hints are the point. "meta.domainVerification is missing" is true and
163
+ * useless; the UI path plus the console URL is the difference between a prompt
164
+ * someone can answer now and one they abandon to go searching.
165
+ */
166
+ function questionFor(provider, needs) {
167
+ const platform = AD_PLATFORMS[provider];
168
+ if (needs === "domainVerification") return {
169
+ provider,
170
+ needs,
171
+ title: `${platform.name} — domain verification token`,
172
+ hints: [...platform.verification.where ? [platform.verification.where] : [], platform.consoleUrl],
173
+ label: platform.verification.txtPrefix ?? "token"
174
+ };
175
+ const what = needs === "pixelId" ? provider === "google" ? "conversion ID (AW-…, not the customer ID)" : "pixel / tag ID" : "advertising account ID";
176
+ return {
177
+ provider,
178
+ needs,
179
+ title: `${platform.name} — ${what}`,
180
+ hints: [platform.consoleUrl],
181
+ label: needs
182
+ };
183
+ }
184
+ /** The error a non-interactive run gets instead of a prompt. */
185
+ function missingFieldMessage(provider, needs) {
186
+ const q = questionFor(provider, needs);
187
+ return `site.ads.${provider}.${needs} is not set — ${q.title}.\n` + q.hints.map((h) => ` ${h}`).join("\n") + `\n Run this interactively to be prompted for it, or add it to the config yourself.`;
188
+ }
189
+ /** Whether this run is allowed to ask. Everything that would hang CI is checked here. */
190
+ function canPrompt(opts) {
191
+ if (opts.dry || opts.check || opts.noPrompt) return false;
192
+ return opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
193
+ }
194
+ /**
195
+ * Ask one question. An empty answer means "skip this one" — reported as still
196
+ * blocked rather than treated as a value, so nothing is written and nothing is
197
+ * created on the strength of a stray Enter.
198
+ */
199
+ async function ask(q) {
200
+ const rl = createInterface({
201
+ input: process.stdin,
202
+ output: process.stdout
203
+ });
204
+ try {
205
+ console.log(`\n${q.title}`);
206
+ for (const hint of q.hints) console.log(` ${hint}`);
207
+ return (await rl.question(` ${q.label} `)).trim() || void 0;
208
+ } finally {
209
+ rl.close();
210
+ }
211
+ }
212
+ /**
213
+ * Merge one ad-property field into a **raw, on-disk** config object.
214
+ *
215
+ * Raw is load-bearing, and it is the rule `@getvitops/vite`'s `designSystemPath()`
216
+ * already follows for the theme editor's save: `resolveConfig` normalises shorthand
217
+ * shapes and resolves `extends` in memory, so writing from the resolved object
218
+ * would grow keys beside the author's and silently edit a copy nothing builds from.
219
+ * Everything outside `site.ads.<provider>.<field>` is left exactly as it was.
220
+ */
221
+ function patchRaw(raw, provider, field, value) {
222
+ if (typeof raw !== "object" || raw == null || Array.isArray(raw)) throw new Error("config is not an object");
223
+ const root = raw;
224
+ const site = root["site"] ?? {};
225
+ const ads = site["ads"] ?? {};
226
+ const entry = ads[provider] ?? {};
227
+ return {
228
+ ...root,
229
+ site: {
230
+ ...site,
231
+ ads: {
232
+ ...ads,
233
+ [provider]: {
234
+ ...entry,
235
+ [field]: value
236
+ }
237
+ }
238
+ }
239
+ };
240
+ }
241
+ /** Indentation the file already uses, so a patch doesn't reformat the whole document. */
242
+ function detectIndent(source) {
243
+ const match = /\n([ \t]+)"/.exec(source);
244
+ if (!match?.[1]) return 2;
245
+ return match[1].includes(" ") ? " " : match[1].length;
246
+ }
247
+ /**
248
+ * Persist one answered field into the config file.
249
+ *
250
+ * JSON only: a `.js`/`.ts` config is code, and rewriting code to insert a value is
251
+ * a different (and much worse) problem than merging an object — so the caller is
252
+ * told to paste it instead. Returns whether it wrote.
253
+ */
254
+ function writeConfigPatch(path, provider, field, value) {
255
+ if (!path.endsWith(".json")) return {
256
+ written: false,
257
+ reason: `${path} is a module, not JSON — add "${field}": "${value}" to site.ads.${provider} yourself`
258
+ };
259
+ if (!existsSync(path)) return {
260
+ written: false,
261
+ reason: `${path} no longer exists`
262
+ };
263
+ const source = readFileSync(path, "utf8");
264
+ const next = patchRaw(JSON.parse(source), provider, field, value);
265
+ const trailingNewline = source.endsWith("\n") ? "\n" : "";
266
+ writeFileSync(path, JSON.stringify(next, null, detectIndent(source)) + trailingNewline);
267
+ return { written: true };
268
+ }
269
+
129
270
  //#endregion
130
271
  //#region src/css-scan.ts
131
272
  /** Strip comments, preserving newlines so line numbers survive. */
@@ -558,6 +699,7 @@ function lintSource(files, v, format) {
558
699
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
559
700
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
560
701
  * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
702
+ * vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
561
703
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
562
704
  *
563
705
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
@@ -602,6 +744,7 @@ Usage:
602
744
  vitops legal [options] Render legal documents from a site config
603
745
  vitops icons [options] Report which icons your source uses, and build the sprite
604
746
  vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
747
+ vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
605
748
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
606
749
 
607
750
  Generate options:
@@ -725,6 +868,53 @@ Search notify options:
725
868
  _REFRESH_TOKEN). The service account is preferred when both are set — this runs
726
869
  on every deploy, and it does not expire.
727
870
 
871
+ Ads subcommands:
872
+ vitops ads setup [opts] Ensure each platform's domain-verification DNS record
873
+ vitops ads tags [opts] Print the consent-gated pixel snippets
874
+ vitops ads lint [opts] Report ad properties the rest of the config can't see
875
+
876
+ Ads setup options:
877
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
878
+ --site-env <env> Environment whose A/B variant applies (default: production)
879
+ --provider <name> Scope to one site.ads entry (google | meta | linkedin |
880
+ reddit | tiktok | microsoft | pinterest | snapchat)
881
+ --dry Print the plan and exit. Creates nothing — but it does READ
882
+ live DNS first, so it needs CLOUDFLARE_API_TOKEN.
883
+ --check Report drift and exit non-zero if any property is not
884
+ linked. Mutates nothing, prompts for nothing.
885
+ --no-prompt Never ask for a missing token; fail with the field name
886
+ instead (the default when stdin is not a TTY, e.g. in CI)
887
+ --no-write Don't persist an answered token back into the config
888
+
889
+ Four platforms verify a domain by DNS TXT — Meta, TikTok, Pinterest, Snapchat —
890
+ and that record is the one thing this creates for you (in Cloudflare, via
891
+ CLOUDFLARE_API_TOKEN; created only, never edited or deleted). Google Ads,
892
+ LinkedIn, Reddit and Microsoft Ads have no domain verification at all: linking
893
+ there is the tag and the account ID, and the run says so rather than skipping in
894
+ silence. No platform Marketing API is called — Meta's needs a system-user token
895
+ and Google's needs an approved developer token, so the final "Verify" click stays
896
+ a reminder. The verification token is not a secret (it is published in DNS, which
897
+ is the ownership proof), so it lives in the config and is prompted for on the
898
+ first run.
899
+
900
+ Ads tags options:
901
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
902
+ --provider <name> Print one platform's tag
903
+ --strategy <s> idle | async | interaction (default: idle)
904
+
905
+ Prints each pixel as an INERT, consent-gated <script>: type="text/plain" with the
906
+ library URL on data-src, so an undecided visitor's page issues no third-party
907
+ request. For stacks with no Astro integration — Bricks, WordPress, Eleventy.
908
+ Paste into your template; @getvitops/core's consent runtime activates them.
909
+
910
+ Ads lint options:
911
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
912
+
913
+ Reports the gaps that are invisible at runtime: a click-ID parameter this
914
+ platform stamps that attribution doesn't capture, a pixel while site.tracking is
915
+ off (every conversion arrives unattributed), and a configured property with no
916
+ tag ID. Exits non-zero on a finding.
917
+
728
918
  Media options:
729
919
  --raw <dir> Directory of unprocessed video, walked recursively
730
920
  (default: ./raw)
@@ -758,6 +948,14 @@ const SEARCH_HELP = `vitops search — Google Search Console
758
948
  vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
759
949
  vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
760
950
 
951
+ Run \`vitops --help\` for the full option list for each subcommand.
952
+ `;
953
+ const ADS_HELP = `vitops ads — link this site to its ad properties
954
+
955
+ vitops ads setup [opts] Ensure each platform's domain-verification DNS record
956
+ vitops ads tags [opts] Print the consent-gated pixel snippets
957
+ vitops ads lint [opts] Report ad properties the rest of the config can't see
958
+
761
959
  Run \`vitops --help\` for the full option list for each subcommand.
762
960
  `;
763
961
  function fail(msg) {
@@ -1012,7 +1210,7 @@ async function cmdMedia(argv) {
1012
1210
  onProgress: (message) => console.error(` … ${message}`)
1013
1211
  });
1014
1212
  if (values.dry) {
1015
- console.log(formatPlan$2(result.plan));
1213
+ console.log(formatPlan$3(result.plan));
1016
1214
  return;
1017
1215
  }
1018
1216
  console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
@@ -1255,6 +1453,15 @@ async function cmdLegal(argv) {
1255
1453
  docs,
1256
1454
  output
1257
1455
  });
1456
+ /**
1457
+ * A processor placed nowhere cannot appear in the cross-border disclosure — there
1458
+ * is nothing true to say about it — so it drops out of that section silently,
1459
+ * which is the failure that looks tidy. Reported here rather than in the document:
1460
+ * a policy must not editorialise about its own gaps.
1461
+ *
1462
+ * On **stderr** because stdout is the document and gets piped or redirected.
1463
+ */
1464
+ if (docs.includes("privacy")) for (const name of processorsMissingLocation(site)) console.error(` ! ${name}: no country, storage or operatorCountry declared — it will not appear in the cross-border transfer disclosure. Add one, or state storage: [{ country: "${JURISDICTION_COUNTRIES[site.site.legal?.jurisdiction ?? "ca"]}" }] to say it does not leave the country.`);
1258
1465
  if (!values.out) {
1259
1466
  process.stdout.write(Object.values(files).join("\n"));
1260
1467
  return;
@@ -1729,6 +1936,320 @@ async function cmdSearchSetup(argv) {
1729
1936
  console.log(`\n${formatSummary(results)}`);
1730
1937
  if (failed) process.exit(1);
1731
1938
  }
1939
+ /**
1940
+ * Adapt a `Config` to the ads module's own option shape.
1941
+ *
1942
+ * The same flat field map as `toSearchConsoleSetup` and `toIndexingConfig`, for the
1943
+ * same reason — utils cannot import the generator. The domain default is the one
1944
+ * derived value: a platform verifies the host the site is published at, so
1945
+ * `domains.canonical` answers it, and only an ad account verified against some
1946
+ * other host needs `site.ads.<provider>.domain` stated.
1947
+ */
1948
+ function toAdProperties(cfg, providerFilter) {
1949
+ const canonicalHost = (() => {
1950
+ const url = cfg.site.domains?.canonical;
1951
+ if (!url) return void 0;
1952
+ try {
1953
+ return new URL(url).hostname;
1954
+ } catch {
1955
+ return;
1956
+ }
1957
+ })();
1958
+ const entries = cfg.site.ads ?? {};
1959
+ const properties = AD_PROVIDER_KEYS.filter((p) => entries[p] != null).map((provider) => {
1960
+ const e = entries[provider];
1961
+ const domain = e.domain ?? canonicalHost;
1962
+ return {
1963
+ provider,
1964
+ ...e.accountId ? { accountId: e.accountId } : {},
1965
+ ...e.pixelId ? { pixelId: e.pixelId } : {},
1966
+ ...e.conversionLabel ? { conversionLabel: e.conversionLabel } : {},
1967
+ ...domain ? { domain } : {},
1968
+ ...e.domainVerification ? { domainVerification: e.domainVerification } : {},
1969
+ ...e.category ? { category: e.category } : {},
1970
+ ...e.enabled != null ? { enabled: e.enabled } : {}
1971
+ };
1972
+ });
1973
+ if (providerFilter) {
1974
+ if (!isAdProvider(providerFilter)) fail(`--provider "${providerFilter}" is not a known ad platform (expected: ${AD_PROVIDER_KEYS.join(" | ")})`);
1975
+ const one = properties.find((p) => p.provider === providerFilter);
1976
+ if (!one) fail(`--provider "${providerFilter}" is not in site.ads (have: ${properties.map((p) => p.provider).join(", ") || "none"})`);
1977
+ return [one];
1978
+ }
1979
+ return properties;
1980
+ }
1981
+ /**
1982
+ * `vitops ads` — everything that links this site to an ad platform.
1983
+ *
1984
+ * Three subcommands, split by what they touch: `setup` writes DNS, `tags` writes
1985
+ * nothing and prints markup, `lint` reads and reports.
1986
+ */
1987
+ async function cmdAds(argv) {
1988
+ const [sub, ...rest] = argv;
1989
+ switch (sub) {
1990
+ case "setup": return cmdAdsSetup(rest);
1991
+ case "tags": return cmdAdsTags(rest);
1992
+ case "lint": return cmdAdsLint(rest);
1993
+ case void 0:
1994
+ case "-h":
1995
+ case "--help":
1996
+ console.log(ADS_HELP);
1997
+ return;
1998
+ default: fail(`unknown "ads" subcommand "${sub}" (expected: setup | tags | lint). Try: vitops --help`);
1999
+ }
2000
+ }
2001
+ /**
2002
+ * Link each configured ad property: ensure the platform's domain-verification TXT,
2003
+ * report what only a human can finish.
2004
+ *
2005
+ * Idempotent by construction — the planner decides each step from live DNS, so a
2006
+ * re-run of a linked property is all skips. `--check` reports drift and exits
2007
+ * non-zero without mutating; `--dry` prints the plan and stops. Neither is offline:
2008
+ * the DNS observation runs before the planner either way.
2009
+ *
2010
+ * The one thing this does that `search setup` does not is **ask**. A first run has
2011
+ * no verification token, because the token doesn't exist until someone fetches it
2012
+ * from the platform UI — so a blocked step becomes a prompt, the answer is folded
2013
+ * into the setup, and the planner runs again on it. That re-plan matters: what gets
2014
+ * executed is always a plan the pure planner produced, never a special case built
2015
+ * out of an answer.
2016
+ */
2017
+ async function cmdAdsSetup(argv) {
2018
+ const { values } = parseArgs({
2019
+ args: argv,
2020
+ options: {
2021
+ input: {
2022
+ type: "string",
2023
+ short: "i",
2024
+ default: "site.json"
2025
+ },
2026
+ "site-env": {
2027
+ type: "string",
2028
+ default: "production"
2029
+ },
2030
+ provider: { type: "string" },
2031
+ check: {
2032
+ type: "boolean",
2033
+ default: false
2034
+ },
2035
+ dry: {
2036
+ type: "boolean",
2037
+ default: false
2038
+ },
2039
+ "no-prompt": {
2040
+ type: "boolean",
2041
+ default: false
2042
+ },
2043
+ "no-write": {
2044
+ type: "boolean",
2045
+ default: false
2046
+ }
2047
+ },
2048
+ allowPositionals: false
2049
+ });
2050
+ const input = resolve(values.input);
2051
+ let properties = toAdProperties(await loadConfig(input, values["site-env"]), values.provider);
2052
+ if (properties.length === 0) fail("no properties in site.ads — add the ad platforms this site is linked to");
2053
+ const dry = values.dry;
2054
+ const check = values.check;
2055
+ const needsDns = properties.some((p) => AD_PLATFORMS[p.provider].verification.method === "dns-txt");
2056
+ const cfToken = loadCloudflareToken();
2057
+ if (needsDns && !cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
2058
+ /** Observe live DNS per domain. Cached by domain: several platforms share one. */
2059
+ const observe = async () => {
2060
+ const states = /* @__PURE__ */ new Map();
2061
+ if (!cfToken) return states;
2062
+ for (const p of properties) {
2063
+ const domain = p.domain;
2064
+ if (!domain || states.has(domain)) continue;
2065
+ if (AD_PLATFORMS[p.provider].verification.method !== "dns-txt") continue;
2066
+ const zone = await findZoneId(cfToken, domain);
2067
+ if (!zone.ok || !zone.zoneId) fail(`${domain}: ${zone.message}`);
2068
+ const txt = await listApexTxt(cfToken, zone.zoneId, domain);
2069
+ if (!txt.ok) fail(`${domain}: ${txt.message}`);
2070
+ states.set(domain, {
2071
+ zoneId: zone.zoneId,
2072
+ txtContents: txt.contents
2073
+ });
2074
+ }
2075
+ return states;
2076
+ };
2077
+ let states = await observe();
2078
+ let adsPlan = plan$2({ properties }, states);
2079
+ const interactive = canPrompt({
2080
+ dry,
2081
+ check,
2082
+ noPrompt: values["no-prompt"]
2083
+ });
2084
+ const gaps = missingFields(adsPlan);
2085
+ if (gaps.length && interactive) {
2086
+ const answers = [];
2087
+ for (const gap of gaps) {
2088
+ const value = await ask(questionFor(gap.provider, gap.needs));
2089
+ if (value) answers.push({
2090
+ provider: gap.provider,
2091
+ needs: gap.needs,
2092
+ value
2093
+ });
2094
+ }
2095
+ if (answers.length) {
2096
+ properties = properties.map((p) => {
2097
+ return answers.filter((a) => a.provider === p.provider).reduce((acc, a) => ({
2098
+ ...acc,
2099
+ [a.needs]: a.value
2100
+ }), p);
2101
+ });
2102
+ adsPlan = plan$2({ properties }, states);
2103
+ if (!values["no-write"]) for (const a of answers) {
2104
+ const res = writeConfigPatch(input, a.provider, a.needs, a.value);
2105
+ if (res.written) console.log(`✓ wrote site.ads.${a.provider}.${a.needs} to ${input}`);
2106
+ else console.error(`! ${res.reason}`);
2107
+ }
2108
+ }
2109
+ states = await observe();
2110
+ adsPlan = plan$2({ properties }, states);
2111
+ } else if (gaps.length && !dry && !check) for (const gap of gaps) console.error(`! ${missingFieldMessage(gap.provider, gap.needs)}`);
2112
+ if (dry || check) {
2113
+ console.log(formatPlan$2(adsPlan));
2114
+ if (check && hasDrift$1(adsPlan)) {
2115
+ console.error("\n✖ ad properties are not fully linked");
2116
+ process.exit(1);
2117
+ }
2118
+ return;
2119
+ }
2120
+ const results = [];
2121
+ let failed = false;
2122
+ for (const p of adsPlan.properties) {
2123
+ const result = {
2124
+ provider: p.provider,
2125
+ domain: p.domain ?? "—",
2126
+ txt: "n/a",
2127
+ tag: p.tag.action === "skip" ? "ready" : "blocked",
2128
+ reminders: p.reminders
2129
+ };
2130
+ if (p.txt.action === "blocked" || p.tag.action === "blocked") failed = true;
2131
+ if (p.txt.action === "blocked") result.txt = "blocked";
2132
+ else if (p.txt.action === "skip") result.txt = AD_PLATFORMS[p.provider].verification.method === "dns-txt" ? "present" : "n/a";
2133
+ else {
2134
+ const zoneId = p.domain ? states.get(p.domain)?.zoneId : void 0;
2135
+ if (!cfToken || !zoneId || !p.domain || !p.txtContent) {
2136
+ console.error(`✖ ${p.provider}: no Cloudflare zone for ${p.domain ?? "(no domain)"}`);
2137
+ result.txt = "failed";
2138
+ failed = true;
2139
+ } else {
2140
+ const r = await createApexTxt(cfToken, zoneId, p.domain, p.txtContent);
2141
+ if (!r.ok) {
2142
+ console.error(`✖ ${p.provider} TXT: ${r.message}`);
2143
+ result.txt = "failed";
2144
+ failed = true;
2145
+ } else {
2146
+ console.log(`✓ ${p.provider}: created TXT on ${p.domain}`);
2147
+ result.txt = "created";
2148
+ }
2149
+ }
2150
+ }
2151
+ results.push(result);
2152
+ }
2153
+ console.log(`\n${formatSummary$1(results)}`);
2154
+ if (results.some((r) => r.txt === "created")) console.log("\n DNS takes minutes to propagate before a platform can see the record.");
2155
+ if (results.some((r) => r.reminders.length)) console.log(" Finish the reminders above in the platform UI — no API covers those.");
2156
+ if (failed) process.exit(1);
2157
+ }
2158
+ /**
2159
+ * Print each configured pixel as an inert, consent-gated `<script>`.
2160
+ *
2161
+ * The universal delivery path, the same reasoning as `vitops legal`: every consumer
2162
+ * has this CLI whatever their stack, so Bricks, WordPress and a hand-built site all
2163
+ * get the correct gated markup without integration code.
2164
+ */
2165
+ async function cmdAdsTags(argv) {
2166
+ const { values } = parseArgs({
2167
+ args: argv,
2168
+ options: {
2169
+ input: {
2170
+ type: "string",
2171
+ short: "i",
2172
+ default: "site.json"
2173
+ },
2174
+ "site-env": {
2175
+ type: "string",
2176
+ default: "production"
2177
+ },
2178
+ provider: { type: "string" },
2179
+ strategy: {
2180
+ type: "string",
2181
+ default: "idle"
2182
+ }
2183
+ },
2184
+ allowPositionals: false
2185
+ });
2186
+ const strategy = values.strategy;
2187
+ if (![
2188
+ "idle",
2189
+ "async",
2190
+ "interaction"
2191
+ ].includes(strategy)) fail(`--strategy "${strategy}" is not one of: idle | async | interaction`);
2192
+ const properties = toAdProperties(await loadConfig(resolve(values.input), values["site-env"]), values.provider);
2193
+ if (properties.length === 0) fail("no properties in site.ads — nothing to emit");
2194
+ const out = [];
2195
+ for (const p of properties) {
2196
+ if (p.enabled === false) continue;
2197
+ const tag = renderTag(p, strategy);
2198
+ if (!tag) {
2199
+ console.error(`! ${p.provider}: no ${AD_PLATFORMS[p.provider].tag.needs} — skipped (run \`vitops ads setup\` to be prompted for it)`);
2200
+ continue;
2201
+ }
2202
+ out.push(tag);
2203
+ }
2204
+ if (out.length === 0) fail("no ad property has a tag id yet");
2205
+ console.log(out.join("\n\n"));
2206
+ }
2207
+ /**
2208
+ * Report the ad-property gaps that are invisible at runtime.
2209
+ *
2210
+ * Each finding is a silent failure, which is the bar for being here at all: an
2211
+ * uncaptured click ID looks exactly like organic traffic, and a pixel on a site
2212
+ * with tracking off produces conversions no campaign gets credit for.
2213
+ */
2214
+ async function cmdAdsLint(argv) {
2215
+ const { values } = parseArgs({
2216
+ args: argv,
2217
+ options: {
2218
+ input: {
2219
+ type: "string",
2220
+ short: "i",
2221
+ default: "site.json"
2222
+ },
2223
+ "site-env": {
2224
+ type: "string",
2225
+ default: "production"
2226
+ }
2227
+ },
2228
+ allowPositionals: false
2229
+ });
2230
+ const cfg = await loadConfig(resolve(values.input), values["site-env"]);
2231
+ const properties = toAdProperties(cfg, void 0);
2232
+ if (properties.length === 0) {
2233
+ console.log("· no site.ads properties — nothing to check");
2234
+ return;
2235
+ }
2236
+ const findings = [];
2237
+ const trackingOn = cfg.site.tracking?.enabled === true;
2238
+ for (const p of properties) {
2239
+ const platform = AD_PLATFORMS[p.provider];
2240
+ if (p.enabled === false) continue;
2241
+ if (!tagId(p)) findings.push(`${p.provider}: no ${platform.tag.needs} — the property is on record but no tag can be emitted`);
2242
+ const uncaptured = platform.clickIdParams.filter((param) => !(param in PLATFORM_PARAMS));
2243
+ if (uncaptured.length) findings.push(`${p.provider}: click ID ${uncaptured.join(", ")} is not in the capture vocabulary — every conversion from this platform will arrive unattributed`);
2244
+ if (!trackingOn) findings.push(`${p.provider}: site.tracking.enabled is not true, so ${platform.clickIdParams.join("/")} is never captured — conversions will be unattributed`);
2245
+ }
2246
+ if (findings.length === 0) {
2247
+ console.log(`✓ ${properties.length} ad propert${properties.length === 1 ? "y" : "ies"} check out`);
2248
+ return;
2249
+ }
2250
+ for (const f of findings) console.error(`✖ ${f}`);
2251
+ process.exit(1);
2252
+ }
1732
2253
  function cliVersion() {
1733
2254
  try {
1734
2255
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
@@ -1955,8 +2476,9 @@ async function main() {
1955
2476
  case "legal": return cmdLegal(rest);
1956
2477
  case "icons": return cmdIcons(rest);
1957
2478
  case "search": return cmdSearch(rest);
2479
+ case "ads": return cmdAds(rest);
1958
2480
  case "media": return cmdMedia(rest);
1959
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | media). Try: vitops --help`);
2481
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | ads | media). Try: vitops --help`);
1960
2482
  }
1961
2483
  }
1962
2484
  main().catch((err) => fail(err.message));