@getvitops/cli 3.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,10 +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
- import { SNAPSHOT_PATH, collectEntries, formatPlan, getAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
10
- import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$1, processMedia } from "@getvitops/utils/media";
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
+ 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 { 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";
11
15
 
12
16
  //#region src/agents.ts
13
17
  /**
@@ -54,6 +58,26 @@ const TOPICS = {
54
58
  path: "concepts/patterns.md",
55
59
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
56
60
  },
61
+ components: {
62
+ path: "concepts/components.md",
63
+ summary: "which of the four tiers provides each pattern, and the call to make"
64
+ },
65
+ consent: {
66
+ path: "concepts/consent.md",
67
+ summary: "the consent gate: inert gated tags, demand-driven prompting, three-valued choices"
68
+ },
69
+ tracking: {
70
+ path: "concepts/tracking.md",
71
+ summary: "ad-click attribution, the `_ac` cookie, conversion notifications"
72
+ },
73
+ search: {
74
+ path: "concepts/search.md",
75
+ summary: "what search engines accept, Search Console onboarding, deploy notification"
76
+ },
77
+ legal: {
78
+ path: "concepts/legal.md",
79
+ summary: "privacy policy / terms / cookie notice derived from config facts"
80
+ },
57
81
  elements: {
58
82
  path: "bricks/elements.md",
59
83
  summary: "Bricks Builder custom elements, controls, defaults"
@@ -105,6 +129,144 @@ function linkSkill(linkPath, targetDir) {
105
129
  }
106
130
  }
107
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
+
108
270
  //#endregion
109
271
  //#region src/css-scan.ts
110
272
  /** Strip comments, preserving newlines so line numbers survive. */
@@ -536,7 +698,8 @@ function lintSource(files, v, format) {
536
698
  * vitops docs [topic] [--input <json>] [--all]
537
699
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
538
700
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
539
- * vitops indexing [--input <site.json>] [--dry] [--all] [--check]
701
+ * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
702
+ * vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
540
703
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
541
704
  *
542
705
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
@@ -580,7 +743,8 @@ Usage:
580
743
  vitops lint [options] Report framework classes in your source that resolve to nothing
581
744
  vitops legal [options] Render legal documents from a site config
582
745
  vitops icons [options] Report which icons your source uses, and build the sprite
583
- vitops indexing [options] Tell search engines about a deploy (sitemap + IndexNow)
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
584
748
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
585
749
 
586
750
  Generate options:
@@ -654,7 +818,29 @@ Legal options:
654
818
  Generated from your config — a starting point, not legal advice. Review before
655
819
  publishing, and make sure the config describes what your site actually does.
656
820
 
657
- Indexing options:
821
+ Search subcommands:
822
+ vitops search setup [opts] Onboard site.searchConsole domains into GSC
823
+ vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
824
+
825
+ Search setup options:
826
+ -i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
827
+ --site-env <env> Environment whose A/B variant applies (default: production)
828
+ --domain <name> Scope to a single site.searchConsole entry
829
+ --dry Print the plan and exit. Changes nothing.
830
+ --check Report drift and exit non-zero if any domain is not fully
831
+ onboarded. Mutates nothing.
832
+
833
+ For each domain it ensures the apex verification TXT in Cloudflare, verifies
834
+ ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
835
+ unverified domain is reported PENDING, not failed), adds the sc-domain: property,
836
+ and adds any delegatedOwners to the web resource. DNS is only ever created,
837
+ never edited or deleted. Credentials come from the environment:
838
+ CLOUDFLARE_API_TOKEN (Zone:DNS:Edit), and VITOPS_GOOGLE_CLIENT_ID /
839
+ VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
840
+ scoped to siteverification + webmasters). Granting a Google Group Full-User
841
+ access has no API and is surfaced as a reminder.
842
+
843
+ Search notify options:
658
844
  -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
659
845
  --site-env <env> Environment to notify for (default: production). An
660
846
  environment whose robots policy says noindex is refused.
@@ -671,13 +857,63 @@ Indexing options:
671
857
  Persist it between runs (a CI cache) or every run submits
672
858
  everything.
673
859
 
674
- What this can and cannot do: Google exposes no "request indexing" API, and its
860
+ What notify can and cannot do: Google exposes no "request indexing" API, and its
675
861
  sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
676
862
  through the Search Console API and verifies the result with --check. IndexNow
677
863
  reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
678
- Search Console needs a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
679
- JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), added as an owner of the
680
- property.
864
+ Search Console takes either credential, whichever you already have, added as an
865
+ owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
866
+ JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
867
+ credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
868
+ _REFRESH_TOKEN). The service account is preferred when both are set — this runs
869
+ on every deploy, and it does not expire.
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.
681
917
 
682
918
  Media options:
683
919
  --raw <dir> Directory of unprocessed video, walked recursively
@@ -707,6 +943,21 @@ Media options:
707
943
  Common:
708
944
  -h, --help Show this help
709
945
  `;
946
+ const SEARCH_HELP = `vitops search — Google Search Console
947
+
948
+ vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
949
+ vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
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
+
959
+ Run \`vitops --help\` for the full option list for each subcommand.
960
+ `;
710
961
  function fail(msg) {
711
962
  console.error(`✖ ${msg}`);
712
963
  process.exit(1);
@@ -959,7 +1210,7 @@ async function cmdMedia(argv) {
959
1210
  onProgress: (message) => console.error(` … ${message}`)
960
1211
  });
961
1212
  if (values.dry) {
962
- console.log(formatPlan$1(result.plan));
1213
+ console.log(formatPlan$3(result.plan));
963
1214
  return;
964
1215
  }
965
1216
  console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
@@ -1202,6 +1453,15 @@ async function cmdLegal(argv) {
1202
1453
  docs,
1203
1454
  output
1204
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.`);
1205
1465
  if (!values.out) {
1206
1466
  process.stdout.write(Object.values(files).join("\n"));
1207
1467
  return;
@@ -1275,7 +1535,7 @@ const readSitemap = async (source) => {
1275
1535
  * The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
1276
1536
  * `gsc.ts`.
1277
1537
  */
1278
- async function cmdIndexing(argv) {
1538
+ async function cmdSearchNotify(argv) {
1279
1539
  const { values } = parseArgs({
1280
1540
  args: argv,
1281
1541
  options: {
@@ -1318,7 +1578,7 @@ async function cmdIndexing(argv) {
1318
1578
  const key = newKey();
1319
1579
  console.log(key);
1320
1580
  console.log(`\n Add to your site config:\n "seo": { "indexing": { "indexNow": { "key": "${key}" } } }`);
1321
- console.log(` Then serve it at /${key}.txt — \`vitops indexing --write-key public\`.`);
1581
+ console.log(` Then serve it at /${key}.txt — \`vitops search notify --write-key public\`.`);
1322
1582
  return;
1323
1583
  }
1324
1584
  const config = toIndexingConfig(await loadConfig(resolve(values.input), values["site-env"]), values["site-env"]);
@@ -1354,13 +1614,13 @@ async function cmdIndexing(argv) {
1354
1614
  console.log("\n(--dry: nothing was submitted)");
1355
1615
  return;
1356
1616
  }
1357
- const account = loadServiceAccount();
1617
+ const credential = loadSearchCredential();
1358
1618
  let failed = false;
1359
1619
  if (values.check) {
1360
1620
  if (!p.searchConsole.siteUrl) fail("--check needs seo.indexing.searchConsole.siteUrl (the Search Console property)");
1361
- if (!account) fail("--check needs a Search Console service account in VITOPS_GSC_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS");
1621
+ if (!credential) fail(`--check needs a Search Console credential — ${CREDENTIAL_HINT}`);
1362
1622
  if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
1363
- const token = await getAccessToken(account);
1623
+ const token = await googleAccessToken(credential);
1364
1624
  console.log(`\nInspecting ${p.check.length} priority URL(s):`);
1365
1625
  for (const url of p.check) {
1366
1626
  const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
@@ -1384,7 +1644,7 @@ async function cmdIndexing(argv) {
1384
1644
  const check = await verifyKeyFile(keyLocation, key);
1385
1645
  if (!check.ok) {
1386
1646
  console.error(`✖ IndexNow key file check failed — ${check.reason}`);
1387
- console.error(` Write it with: vitops indexing --write-key <public dir>`);
1647
+ console.error(` Write it with: vitops search notify --write-key <public dir>`);
1388
1648
  failed = true;
1389
1649
  } else {
1390
1650
  let sent = 0;
@@ -1400,9 +1660,9 @@ async function cmdIndexing(argv) {
1400
1660
  if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
1401
1661
  }
1402
1662
  }
1403
- if (p.searchConsole.enabled) if (!account) console.log("· Search Console: skipped — no VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS");
1663
+ if (p.searchConsole.enabled) if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
1404
1664
  else {
1405
- const r = await submitSitemap(await getAccessToken(account), p.searchConsole.siteUrl, sitemapUrl);
1665
+ const r = await submitSitemap(await googleAccessToken(credential), p.searchConsole.siteUrl, sitemapUrl);
1406
1666
  if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1407
1667
  else {
1408
1668
  console.error(`✖ Search Console: ${r.message}`);
@@ -1414,7 +1674,581 @@ async function cmdIndexing(argv) {
1414
1674
  writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
1415
1675
  console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
1416
1676
  }
1417
- if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops indexing --check\` in a day or two to see what Google actually did.`);
1677
+ if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops search notify --check\` in a day or two to see what Google actually did.`);
1678
+ }
1679
+ /**
1680
+ * Adapt a `Config` to the onboarding module's own option shape.
1681
+ *
1682
+ * The same flat field map as `toIndexingConfig`, and for the same reason — utils
1683
+ * cannot import the generator. `--domain` scopes to a single `site.searchConsole`
1684
+ * entry, and a name that isn't there is an error, not an empty run.
1685
+ */
1686
+ function toSearchConsoleSetup(cfg, domainFilter) {
1687
+ const entries = cfg.site.searchConsole ?? {};
1688
+ const setups = Object.entries(entries).map(([domain, e]) => ({
1689
+ domain,
1690
+ ...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
1691
+ ...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
1692
+ }));
1693
+ if (domainFilter) {
1694
+ const one = setups.find((s) => s.domain === domainFilter);
1695
+ if (!one) fail(`--domain "${domainFilter}" is not in site.searchConsole (have: ${setups.map((s) => s.domain).join(", ") || "none"})`);
1696
+ return [one];
1697
+ }
1698
+ return setups;
1699
+ }
1700
+ /**
1701
+ * The Cloudflare DNS credential. From the environment only — a `site.json` is
1702
+ * committed, and this is a real secret. A `Zone:DNS:Edit` token (the standard
1703
+ * "Edit zone DNS" template, which also carries `Zone:Read`).
1704
+ */
1705
+ function loadCloudflareToken() {
1706
+ return process.env.CLOUDFLARE_API_TOKEN || void 0;
1707
+ }
1708
+ /**
1709
+ * The Google user-OAuth credential (refresh-token flow), from the environment.
1710
+ *
1711
+ * A user token, not a service account: onboarding acts *as a person* who can own
1712
+ * the sites. All-or-nothing — a partial set is a misconfiguration worth naming
1713
+ * rather than a reason to run degraded.
1714
+ */
1715
+ function loadGoogleOAuth() {
1716
+ const clientId = process.env.VITOPS_GOOGLE_CLIENT_ID;
1717
+ const clientSecret = process.env.VITOPS_GOOGLE_CLIENT_SECRET;
1718
+ const refreshToken = process.env.VITOPS_GOOGLE_REFRESH_TOKEN;
1719
+ if (!clientId && !clientSecret && !refreshToken) return void 0;
1720
+ const missing = [
1721
+ ["VITOPS_GOOGLE_CLIENT_ID", clientId],
1722
+ ["VITOPS_GOOGLE_CLIENT_SECRET", clientSecret],
1723
+ ["VITOPS_GOOGLE_REFRESH_TOKEN", refreshToken]
1724
+ ].filter(([, v]) => !v).map(([k]) => k);
1725
+ if (missing.length) fail(`incomplete Google OAuth credential — missing ${missing.join(", ")}`);
1726
+ return {
1727
+ clientId,
1728
+ clientSecret,
1729
+ refreshToken
1730
+ };
1731
+ }
1732
+ /**
1733
+ * The Google identity for `search notify`, preferring a service account.
1734
+ *
1735
+ * Search Console accepts either, and the preference is about *where this command
1736
+ * runs*: it fires on every deploy, in CI, and a service account has no expiry,
1737
+ * while a refresh token can be revoked — and for an OAuth client still in
1738
+ * *Testing* publishing status Google expires it after 7 days, which is a bad
1739
+ * property for a deploy step. So the robot wins when it is available.
1740
+ *
1741
+ * The fallback is the point, though. `search setup` needs user OAuth (verifying
1742
+ * a site makes the caller an owner, which should be a person), so a consumer who
1743
+ * ran setup already has a Google credential. Demanding a second, unrelated one
1744
+ * for the other half of the same command bought nothing.
1745
+ */
1746
+ function loadSearchCredential() {
1747
+ const account = loadServiceAccount();
1748
+ if (account) return {
1749
+ kind: "service-account",
1750
+ account,
1751
+ scope: SCOPES.webmasters
1752
+ };
1753
+ const oauth = loadGoogleOAuth();
1754
+ return oauth ? {
1755
+ kind: "oauth",
1756
+ oauth
1757
+ } : void 0;
1758
+ }
1759
+ /** Both spellings, for an error that has to tell you what to actually set. */
1760
+ const CREDENTIAL_HINT = "a service account in VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS, or a user OAuth credential in VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN";
1761
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1762
+ /**
1763
+ * `vitops search` — everything that talks to search engines.
1764
+ *
1765
+ * Two subcommands, split by what they touch: `setup` onboards a domain into
1766
+ * Search Console (DNS + verification + property), `notify` tells the engines a
1767
+ * deploy happened (sitemap + IndexNow). `notify` is the former top-level
1768
+ * `vitops indexing`.
1769
+ */
1770
+ async function cmdSearch(argv) {
1771
+ const [sub, ...rest] = argv;
1772
+ switch (sub) {
1773
+ case "setup": return cmdSearchSetup(rest);
1774
+ case "notify": return cmdSearchNotify(rest);
1775
+ case void 0:
1776
+ case "-h":
1777
+ case "--help":
1778
+ console.log(SEARCH_HELP);
1779
+ return;
1780
+ default: fail(`unknown "search" subcommand "${sub}" (expected: setup | notify). Try: vitops --help`);
1781
+ }
1782
+ }
1783
+ /**
1784
+ * Onboard domains into Google Search Console as domain properties.
1785
+ *
1786
+ * Idempotent by construction: the planner decides each step from the domain's live
1787
+ * state, so a re-run of a fully-onboarded domain is all skips. `--check` reports
1788
+ * drift and exits non-zero without mutating; `--dry` prints the plan and stops.
1789
+ * DNS is only ever *created* — the verification TXT — never edited or removed.
1790
+ *
1791
+ * Search Console has no user/permission API, so granting a Google Group Full-User
1792
+ * access stays manual and is surfaced as a reminder, not attempted.
1793
+ */
1794
+ async function cmdSearchSetup(argv) {
1795
+ const { values } = parseArgs({
1796
+ args: argv,
1797
+ options: {
1798
+ input: {
1799
+ type: "string",
1800
+ short: "i",
1801
+ default: "site.json"
1802
+ },
1803
+ "site-env": {
1804
+ type: "string",
1805
+ default: "production"
1806
+ },
1807
+ domain: { type: "string" },
1808
+ check: {
1809
+ type: "boolean",
1810
+ default: false
1811
+ },
1812
+ dry: {
1813
+ type: "boolean",
1814
+ default: false
1815
+ }
1816
+ },
1817
+ allowPositionals: false
1818
+ });
1819
+ const domains = toSearchConsoleSetup(await loadConfig(resolve(values.input), values["site-env"]), values.domain);
1820
+ if (domains.length === 0) fail("no domains in site.searchConsole — add domains to onboard (keyed by bare hostname)");
1821
+ const cfToken = loadCloudflareToken();
1822
+ const oauth = loadGoogleOAuth();
1823
+ if (!cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
1824
+ if (!oauth) fail("set VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN — a user OAuth token scoped to siteverification + webmasters");
1825
+ const token = await refreshTokenGrant(oauth);
1826
+ const states = /* @__PURE__ */ new Map();
1827
+ const verifyTokens = /* @__PURE__ */ new Map();
1828
+ const webIds = /* @__PURE__ */ new Map();
1829
+ for (const d of domains) {
1830
+ const zone = await findZoneId(cfToken, d.domain);
1831
+ if (!zone.ok || !zone.zoneId) fail(`${d.domain}: ${zone.message}`);
1832
+ const vt = await getVerificationToken(token, d.domain);
1833
+ if (!vt.ok || !vt.token) fail(`${d.domain}: could not obtain a verification token — ${vt.message}`);
1834
+ verifyTokens.set(d.domain, vt.token);
1835
+ const txt = await listApexTxt(cfToken, zone.zoneId, d.domain);
1836
+ if (!txt.ok) fail(`${d.domain}: ${txt.message}`);
1837
+ const wr = await getWebResource(token, d.domain);
1838
+ if (!wr.ok) fail(`${d.domain}: ${wr.message}`);
1839
+ if (wr.id) webIds.set(d.domain, wr.id);
1840
+ const site = await getSite(token, siteUrlFor(d.domain));
1841
+ if (!site.ok) fail(`${d.domain}: ${site.message}`);
1842
+ states.set(d.domain, {
1843
+ zoneId: zone.zoneId,
1844
+ txtPresent: txt.contents.includes(vt.token),
1845
+ verified: wr.exists,
1846
+ currentOwners: wr.owners,
1847
+ propertyExists: site.exists
1848
+ });
1849
+ }
1850
+ const p = plan$1({ domains }, states);
1851
+ console.log(formatPlan$1(p));
1852
+ if (values.check) {
1853
+ if (hasDrift(p)) {
1854
+ console.error("\n✖ drift — one or more domains are not fully onboarded");
1855
+ process.exit(1);
1856
+ }
1857
+ console.log("\n✓ every domain is onboarded");
1858
+ return;
1859
+ }
1860
+ if (values.dry) {
1861
+ console.log("\n(--dry: nothing was changed)");
1862
+ return;
1863
+ }
1864
+ const results = [];
1865
+ let failed = false;
1866
+ const delays = backoffSchedule();
1867
+ for (const d of domains) {
1868
+ const dp = p.domains.find((x) => x.domain === d.domain);
1869
+ const state = states.get(d.domain);
1870
+ const result = {
1871
+ domain: d.domain,
1872
+ txt: "—",
1873
+ verified: "—",
1874
+ property: "—",
1875
+ reminders: dp.reminders
1876
+ };
1877
+ if (dp.txt.action === "skip") result.txt = "present";
1878
+ else {
1879
+ const r = await createApexTxt(cfToken, state.zoneId, d.domain, verifyTokens.get(d.domain));
1880
+ if (!r.ok) {
1881
+ console.error(`✖ ${d.domain} TXT: ${r.message}`);
1882
+ result.txt = "failed";
1883
+ failed = true;
1884
+ results.push(result);
1885
+ continue;
1886
+ }
1887
+ result.txt = "created";
1888
+ }
1889
+ let verified = state.verified;
1890
+ if (verified) result.verified = "yes";
1891
+ else {
1892
+ for (let attempt = 0; attempt < delays.length; attempt++) {
1893
+ const v = await verifyWebResource(token, d.domain);
1894
+ if (v.ok) {
1895
+ verified = true;
1896
+ if (v.id) webIds.set(d.domain, v.id);
1897
+ state.currentOwners = v.owners;
1898
+ break;
1899
+ }
1900
+ if (attempt < delays.length - 1) {
1901
+ const wait = delays[attempt];
1902
+ console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
1903
+ await sleep(wait);
1904
+ }
1905
+ }
1906
+ result.verified = verified ? "yes" : "pending";
1907
+ }
1908
+ if (!verified) {
1909
+ results.push(result);
1910
+ continue;
1911
+ }
1912
+ if (dp.property.action === "skip") result.property = "present";
1913
+ else {
1914
+ const r = await addSite(token, dp.siteUrl);
1915
+ if (!r.ok) {
1916
+ console.error(`✖ ${d.domain} property: ${r.message}`);
1917
+ result.property = "failed";
1918
+ failed = true;
1919
+ } else result.property = "added";
1920
+ }
1921
+ if (dp.owners.action === "update" && dp.ownersToAdd.length) {
1922
+ const id = webIds.get(d.domain);
1923
+ if (!id) {
1924
+ console.error(`✖ ${d.domain} owners: no web-resource id (verify may be too fresh)`);
1925
+ failed = true;
1926
+ } else {
1927
+ const r = await updateOwners(token, id, d.domain, ownerUnion(state.currentOwners, dp.ownersToAdd));
1928
+ if (!r.ok) {
1929
+ console.error(`✖ ${d.domain} owners: ${r.message}`);
1930
+ failed = true;
1931
+ } else console.log(`✓ ${d.domain}: added ${dp.ownersToAdd.length} owner(s)`);
1932
+ }
1933
+ }
1934
+ results.push(result);
1935
+ }
1936
+ console.log(`\n${formatSummary(results)}`);
1937
+ if (failed) process.exit(1);
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);
1418
2252
  }
1419
2253
  function cliVersion() {
1420
2254
  try {
@@ -1641,9 +2475,10 @@ async function main() {
1641
2475
  case "lint": return cmdLint(rest);
1642
2476
  case "legal": return cmdLegal(rest);
1643
2477
  case "icons": return cmdIcons(rest);
1644
- case "indexing": return cmdIndexing(rest);
2478
+ case "search": return cmdSearch(rest);
2479
+ case "ads": return cmdAds(rest);
1645
2480
  case "media": return cmdMedia(rest);
1646
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | indexing | 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`);
1647
2482
  }
1648
2483
  }
1649
2484
  main().catch((err) => fail(err.message));