@getvitops/cli 2.1.0 → 4.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.
package/dist/cli.mjs CHANGED
@@ -4,10 +4,11 @@ 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, isSiteConfig, resolveInput, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
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";
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 { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$2, processMedia } from "@getvitops/utils/media";
11
12
 
12
13
  //#region src/agents.ts
13
14
  /**
@@ -30,6 +31,10 @@ const TOPICS = {
30
31
  path: "authoring.md",
31
32
  summary: "every design-system.json field, rendered from the JSON Schema"
32
33
  },
34
+ config: {
35
+ path: "config.md",
36
+ summary: "every field of the three-section config (designSystem / organization / site), rendered from the JSON Schema"
37
+ },
33
38
  formats: {
34
39
  path: "formats.md",
35
40
  summary: "tailwind vs css vs bricks: what's emitted vs what the platform provides"
@@ -50,6 +55,26 @@ const TOPICS = {
50
55
  path: "concepts/patterns.md",
51
56
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
52
57
  },
58
+ components: {
59
+ path: "concepts/components.md",
60
+ summary: "which of the four tiers provides each pattern, and the call to make"
61
+ },
62
+ consent: {
63
+ path: "concepts/consent.md",
64
+ summary: "the consent gate: inert gated tags, demand-driven prompting, three-valued choices"
65
+ },
66
+ tracking: {
67
+ path: "concepts/tracking.md",
68
+ summary: "ad-click attribution, the `_ac` cookie, conversion notifications"
69
+ },
70
+ search: {
71
+ path: "concepts/search.md",
72
+ summary: "what search engines accept, Search Console onboarding, deploy notification"
73
+ },
74
+ legal: {
75
+ path: "concepts/legal.md",
76
+ summary: "privacy policy / terms / cookie notice derived from config facts"
77
+ },
53
78
  elements: {
54
79
  path: "bricks/elements.md",
55
80
  summary: "Bricks Builder custom elements, controls, defaults"
@@ -532,7 +557,7 @@ function lintSource(files, v, format) {
532
557
  * vitops docs [topic] [--input <json>] [--all]
533
558
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
534
559
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
535
- * vitops indexing [--input <site.json>] [--dry] [--all] [--check]
560
+ * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
536
561
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
537
562
  *
538
563
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
@@ -576,7 +601,7 @@ Usage:
576
601
  vitops lint [options] Report framework classes in your source that resolve to nothing
577
602
  vitops legal [options] Render legal documents from a site config
578
603
  vitops icons [options] Report which icons your source uses, and build the sprite
579
- vitops indexing [options] Tell search engines about a deploy (sitemap + IndexNow)
604
+ vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
580
605
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
581
606
 
582
607
  Generate options:
@@ -650,7 +675,29 @@ Legal options:
650
675
  Generated from your config — a starting point, not legal advice. Review before
651
676
  publishing, and make sure the config describes what your site actually does.
652
677
 
653
- Indexing options:
678
+ Search subcommands:
679
+ vitops search setup [opts] Onboard site.searchConsole domains into GSC
680
+ vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
681
+
682
+ Search setup options:
683
+ -i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
684
+ --site-env <env> Environment whose A/B variant applies (default: production)
685
+ --domain <name> Scope to a single site.searchConsole entry
686
+ --dry Print the plan and exit. Changes nothing.
687
+ --check Report drift and exit non-zero if any domain is not fully
688
+ onboarded. Mutates nothing.
689
+
690
+ For each domain it ensures the apex verification TXT in Cloudflare, verifies
691
+ ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
692
+ unverified domain is reported PENDING, not failed), adds the sc-domain: property,
693
+ and adds any delegatedOwners to the web resource. DNS is only ever created,
694
+ never edited or deleted. Credentials come from the environment:
695
+ CLOUDFLARE_API_TOKEN (Zone:DNS:Edit), and VITOPS_GOOGLE_CLIENT_ID /
696
+ VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
697
+ scoped to siteverification + webmasters). Granting a Google Group Full-User
698
+ access has no API and is surfaced as a reminder.
699
+
700
+ Search notify options:
654
701
  -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
655
702
  --site-env <env> Environment to notify for (default: production). An
656
703
  environment whose robots policy says noindex is refused.
@@ -667,13 +714,16 @@ Indexing options:
667
714
  Persist it between runs (a CI cache) or every run submits
668
715
  everything.
669
716
 
670
- What this can and cannot do: Google exposes no "request indexing" API, and its
717
+ What notify can and cannot do: Google exposes no "request indexing" API, and its
671
718
  sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
672
719
  through the Search Console API and verifies the result with --check. IndexNow
673
720
  reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
674
- Search Console needs a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
675
- JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), added as an owner of the
676
- property.
721
+ Search Console takes either credential, whichever you already have, added as an
722
+ owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
723
+ JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
724
+ credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
725
+ _REFRESH_TOKEN). The service account is preferred when both are set — this runs
726
+ on every deploy, and it does not expire.
677
727
 
678
728
  Media options:
679
729
  --raw <dir> Directory of unprocessed video, walked recursively
@@ -703,6 +753,13 @@ Media options:
703
753
  Common:
704
754
  -h, --help Show this help
705
755
  `;
756
+ const SEARCH_HELP = `vitops search — Google Search Console
757
+
758
+ vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
759
+ vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
760
+
761
+ Run \`vitops --help\` for the full option list for each subcommand.
762
+ `;
706
763
  function fail(msg) {
707
764
  console.error(`✖ ${msg}`);
708
765
  process.exit(1);
@@ -804,10 +861,10 @@ function cmdValidate(argv) {
804
861
  } catch (err) {
805
862
  fail(`could not parse JSON: ${err.message}`);
806
863
  }
807
- if (isSiteConfig(raw)) {
864
+ if (isConfig(raw)) {
808
865
  let site;
809
866
  try {
810
- site = resolveSiteConfig(raw, values["site-env"]);
867
+ site = resolveConfig(raw, values["site-env"]);
811
868
  } catch (err) {
812
869
  console.error(`✖ ${path} is invalid as a site config:`);
813
870
  console.error(err.message.replace(/^Invalid site config:\n/, ""));
@@ -882,7 +939,7 @@ const OUTPUT_KINDS = new Set([
882
939
  * Flags only, with no config file, following `favicon`: `site.favicon` exists in
883
940
  * the schema and `cmdFavicon` still doesn't read it. The same reasoning applies
884
941
  * more strongly here — `legal`, `icons` and `indexing` are anchored to a
885
- * `SiteConfig` because what they emit *describes the site*, and an encoder setting
942
+ * `Config` because what they emit *describes the site*, and an encoder setting
886
943
  * describes a build step.
887
944
  */
888
945
  async function cmdMedia(argv) {
@@ -955,7 +1012,7 @@ async function cmdMedia(argv) {
955
1012
  onProgress: (message) => console.error(` … ${message}`)
956
1013
  });
957
1014
  if (values.dry) {
958
- console.log(formatPlan$1(result.plan));
1015
+ console.log(formatPlan$2(result.plan));
959
1016
  return;
960
1017
  }
961
1018
  console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
@@ -973,7 +1030,7 @@ async function cmdMedia(argv) {
973
1030
  * `company.json` doesn't have to maintain a second file for the tooling's sake.
974
1031
  * `resolveInput` tells them apart by shape and resolves the theme.
975
1032
  */
976
- function loadConfig(path, theme) {
1033
+ function loadDesignSystem(path, theme) {
977
1034
  if (!existsSync(path)) fail(`config not found: ${path}`);
978
1035
  let raw;
979
1036
  try {
@@ -1001,11 +1058,11 @@ function loadConfig(path, theme) {
1001
1058
  * Read + resolve a site config, or exit with a message.
1002
1059
  *
1003
1060
  * Accepts JSON, or a JS/TS module with a default export — deliberately not YAML,
1004
- * which would mean a parser dependency for one command. `resolveSiteConfig` is
1061
+ * which would mean a parser dependency for one command. `resolveConfig` is
1005
1062
  * documented as taking an already-parsed object precisely so the loader is the
1006
1063
  * consumer's choice; anyone on YAML can convert it or export from a module.
1007
1064
  */
1008
- async function loadSiteConfig(path, siteEnv) {
1065
+ async function loadConfig(path, siteEnv) {
1009
1066
  if (!existsSync(path)) fail(`site config not found: ${path}`);
1010
1067
  let raw;
1011
1068
  if (/\.(json)$/.test(path)) try {
@@ -1022,7 +1079,7 @@ async function loadSiteConfig(path, siteEnv) {
1022
1079
  if (raw == null) fail(`${path} has no default export`);
1023
1080
  }
1024
1081
  try {
1025
- return resolveSiteConfig(raw, siteEnv);
1082
+ return resolveConfig(raw, siteEnv);
1026
1083
  } catch (err) {
1027
1084
  fail(err.message);
1028
1085
  }
@@ -1040,7 +1097,7 @@ async function loadSiteConfig(path, siteEnv) {
1040
1097
  * sprite from it.
1041
1098
  *
1042
1099
  * A sibling of `legal` rather than a widening of `lint`: `lint` judges classes
1043
- * against a design-system.json, whereas icons are anchored to a SiteConfig.
1100
+ * against a design-system.json, whereas icons are anchored to a Config.
1044
1101
  *
1045
1102
  * Exit codes carry the same distinction the Astro integration makes. A name the
1046
1103
  * config DECLARES but that doesn't resolve is a config error and fails the
@@ -1082,7 +1139,7 @@ async function cmdIcons(argv) {
1082
1139
  allowPositionals: false
1083
1140
  });
1084
1141
  const sitePath = resolve(values.site);
1085
- const icons = existsSync(sitePath) ? (await loadSiteConfig(sitePath, values["site-env"])).icons ?? {} : {};
1142
+ const icons = existsSync(sitePath) ? (await loadConfig(sitePath, values["site-env"])).site.icons ?? {} : {};
1086
1143
  const ui = icons.ui ?? "fa7-solid";
1087
1144
  const brand = icons.brand ?? "simple-icons";
1088
1145
  const weight = icons.weight;
@@ -1191,9 +1248,9 @@ async function cmdLegal(argv) {
1191
1248
  if (!LEGAL_OUTPUTS.has(output)) fail(`unknown format "${output}" (expected: ${[...LEGAL_OUTPUTS].join(" | ")})`);
1192
1249
  const requested = values.doc ?? [];
1193
1250
  for (const d of requested) if (!LEGAL_DOCS.has(d)) fail(`unknown doc "${d}" (expected: ${[...LEGAL_DOCS].join(" | ")})`);
1194
- const site = await loadSiteConfig(resolve(values.input), values["site-env"]);
1251
+ const site = await loadConfig(resolve(values.input), values["site-env"]);
1195
1252
  const docs = requested.length ? requested : enabledDocs(site);
1196
- if (docs.length === 0) fail("no legal documents are enabled — set legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your site config, or name one with --doc");
1253
+ if (docs.length === 0) fail("no legal documents are enabled — set site.legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your config, or name one with --doc");
1197
1254
  const files = generateLegal(site, {
1198
1255
  docs,
1199
1256
  output
@@ -1209,14 +1266,15 @@ async function cmdLegal(argv) {
1209
1266
  console.log(" These are generated from your config and are not legal advice — review them before publishing.");
1210
1267
  }
1211
1268
  /**
1212
- * Adapt a `SiteConfig` to the indexing module's own option shape.
1269
+ * Adapt a `Config` to the indexing module's own option shape.
1213
1270
  *
1214
1271
  * A flat field map, as intended: `@getvitops/utils` cannot import from the
1215
1272
  * generator (the generator already depends on it), so `IndexingConfig` mirrors the
1216
1273
  * `seo.indexing` block rather than being it. The one piece of judgment here is the
1217
1274
  * origin — see below.
1218
1275
  */
1219
- function toIndexingConfig(site, siteEnv) {
1276
+ function toIndexingConfig(cfg, siteEnv) {
1277
+ const site = cfg.site;
1220
1278
  const indexing = site.seo?.indexing ?? {};
1221
1279
  const env = site.environments?.[siteEnv];
1222
1280
  const canonical = env?.url ?? site.domains?.canonical;
@@ -1270,7 +1328,7 @@ const readSitemap = async (source) => {
1270
1328
  * The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
1271
1329
  * `gsc.ts`.
1272
1330
  */
1273
- async function cmdIndexing(argv) {
1331
+ async function cmdSearchNotify(argv) {
1274
1332
  const { values } = parseArgs({
1275
1333
  args: argv,
1276
1334
  options: {
@@ -1313,13 +1371,13 @@ async function cmdIndexing(argv) {
1313
1371
  const key = newKey();
1314
1372
  console.log(key);
1315
1373
  console.log(`\n Add to your site config:\n "seo": { "indexing": { "indexNow": { "key": "${key}" } } }`);
1316
- console.log(` Then serve it at /${key}.txt — \`vitops indexing --write-key public\`.`);
1374
+ console.log(` Then serve it at /${key}.txt — \`vitops search notify --write-key public\`.`);
1317
1375
  return;
1318
1376
  }
1319
- const config = toIndexingConfig(await loadSiteConfig(resolve(values.input), values["site-env"]), values["site-env"]);
1377
+ const config = toIndexingConfig(await loadConfig(resolve(values.input), values["site-env"]), values["site-env"]);
1320
1378
  if (values["write-key"] !== void 0) {
1321
1379
  const key = config.indexNow?.key;
1322
- if (!key) fail("no seo.indexing.indexNow.key in the config — generate one with --new-key");
1380
+ if (!key) fail("no site.seo.indexing.indexNow.key in the config — generate one with --new-key");
1323
1381
  const dir = resolve(values["write-key"]);
1324
1382
  mkdirSync(dir, { recursive: true });
1325
1383
  const file = join(dir, `${key}.txt`);
@@ -1349,13 +1407,13 @@ async function cmdIndexing(argv) {
1349
1407
  console.log("\n(--dry: nothing was submitted)");
1350
1408
  return;
1351
1409
  }
1352
- const account = loadServiceAccount();
1410
+ const credential = loadSearchCredential();
1353
1411
  let failed = false;
1354
1412
  if (values.check) {
1355
1413
  if (!p.searchConsole.siteUrl) fail("--check needs seo.indexing.searchConsole.siteUrl (the Search Console property)");
1356
- if (!account) fail("--check needs a Search Console service account in VITOPS_GSC_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS");
1414
+ if (!credential) fail(`--check needs a Search Console credential — ${CREDENTIAL_HINT}`);
1357
1415
  if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
1358
- const token = await getAccessToken(account);
1416
+ const token = await googleAccessToken(credential);
1359
1417
  console.log(`\nInspecting ${p.check.length} priority URL(s):`);
1360
1418
  for (const url of p.check) {
1361
1419
  const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
@@ -1379,7 +1437,7 @@ async function cmdIndexing(argv) {
1379
1437
  const check = await verifyKeyFile(keyLocation, key);
1380
1438
  if (!check.ok) {
1381
1439
  console.error(`✖ IndexNow key file check failed — ${check.reason}`);
1382
- console.error(` Write it with: vitops indexing --write-key <public dir>`);
1440
+ console.error(` Write it with: vitops search notify --write-key <public dir>`);
1383
1441
  failed = true;
1384
1442
  } else {
1385
1443
  let sent = 0;
@@ -1395,9 +1453,9 @@ async function cmdIndexing(argv) {
1395
1453
  if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
1396
1454
  }
1397
1455
  }
1398
- if (p.searchConsole.enabled) if (!account) console.log("· Search Console: skipped — no VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS");
1456
+ if (p.searchConsole.enabled) if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
1399
1457
  else {
1400
- const r = await submitSitemap(await getAccessToken(account), p.searchConsole.siteUrl, sitemapUrl);
1458
+ const r = await submitSitemap(await googleAccessToken(credential), p.searchConsole.siteUrl, sitemapUrl);
1401
1459
  if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1402
1460
  else {
1403
1461
  console.error(`✖ Search Console: ${r.message}`);
@@ -1409,7 +1467,267 @@ async function cmdIndexing(argv) {
1409
1467
  writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
1410
1468
  console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
1411
1469
  }
1412
- 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.`);
1470
+ 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.`);
1471
+ }
1472
+ /**
1473
+ * Adapt a `Config` to the onboarding module's own option shape.
1474
+ *
1475
+ * The same flat field map as `toIndexingConfig`, and for the same reason — utils
1476
+ * cannot import the generator. `--domain` scopes to a single `site.searchConsole`
1477
+ * entry, and a name that isn't there is an error, not an empty run.
1478
+ */
1479
+ function toSearchConsoleSetup(cfg, domainFilter) {
1480
+ const entries = cfg.site.searchConsole ?? {};
1481
+ const setups = Object.entries(entries).map(([domain, e]) => ({
1482
+ domain,
1483
+ ...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
1484
+ ...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
1485
+ }));
1486
+ if (domainFilter) {
1487
+ const one = setups.find((s) => s.domain === domainFilter);
1488
+ if (!one) fail(`--domain "${domainFilter}" is not in site.searchConsole (have: ${setups.map((s) => s.domain).join(", ") || "none"})`);
1489
+ return [one];
1490
+ }
1491
+ return setups;
1492
+ }
1493
+ /**
1494
+ * The Cloudflare DNS credential. From the environment only — a `site.json` is
1495
+ * committed, and this is a real secret. A `Zone:DNS:Edit` token (the standard
1496
+ * "Edit zone DNS" template, which also carries `Zone:Read`).
1497
+ */
1498
+ function loadCloudflareToken() {
1499
+ return process.env.CLOUDFLARE_API_TOKEN || void 0;
1500
+ }
1501
+ /**
1502
+ * The Google user-OAuth credential (refresh-token flow), from the environment.
1503
+ *
1504
+ * A user token, not a service account: onboarding acts *as a person* who can own
1505
+ * the sites. All-or-nothing — a partial set is a misconfiguration worth naming
1506
+ * rather than a reason to run degraded.
1507
+ */
1508
+ function loadGoogleOAuth() {
1509
+ const clientId = process.env.VITOPS_GOOGLE_CLIENT_ID;
1510
+ const clientSecret = process.env.VITOPS_GOOGLE_CLIENT_SECRET;
1511
+ const refreshToken = process.env.VITOPS_GOOGLE_REFRESH_TOKEN;
1512
+ if (!clientId && !clientSecret && !refreshToken) return void 0;
1513
+ const missing = [
1514
+ ["VITOPS_GOOGLE_CLIENT_ID", clientId],
1515
+ ["VITOPS_GOOGLE_CLIENT_SECRET", clientSecret],
1516
+ ["VITOPS_GOOGLE_REFRESH_TOKEN", refreshToken]
1517
+ ].filter(([, v]) => !v).map(([k]) => k);
1518
+ if (missing.length) fail(`incomplete Google OAuth credential — missing ${missing.join(", ")}`);
1519
+ return {
1520
+ clientId,
1521
+ clientSecret,
1522
+ refreshToken
1523
+ };
1524
+ }
1525
+ /**
1526
+ * The Google identity for `search notify`, preferring a service account.
1527
+ *
1528
+ * Search Console accepts either, and the preference is about *where this command
1529
+ * runs*: it fires on every deploy, in CI, and a service account has no expiry,
1530
+ * while a refresh token can be revoked — and for an OAuth client still in
1531
+ * *Testing* publishing status Google expires it after 7 days, which is a bad
1532
+ * property for a deploy step. So the robot wins when it is available.
1533
+ *
1534
+ * The fallback is the point, though. `search setup` needs user OAuth (verifying
1535
+ * a site makes the caller an owner, which should be a person), so a consumer who
1536
+ * ran setup already has a Google credential. Demanding a second, unrelated one
1537
+ * for the other half of the same command bought nothing.
1538
+ */
1539
+ function loadSearchCredential() {
1540
+ const account = loadServiceAccount();
1541
+ if (account) return {
1542
+ kind: "service-account",
1543
+ account,
1544
+ scope: SCOPES.webmasters
1545
+ };
1546
+ const oauth = loadGoogleOAuth();
1547
+ return oauth ? {
1548
+ kind: "oauth",
1549
+ oauth
1550
+ } : void 0;
1551
+ }
1552
+ /** Both spellings, for an error that has to tell you what to actually set. */
1553
+ 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";
1554
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1555
+ /**
1556
+ * `vitops search` — everything that talks to search engines.
1557
+ *
1558
+ * Two subcommands, split by what they touch: `setup` onboards a domain into
1559
+ * Search Console (DNS + verification + property), `notify` tells the engines a
1560
+ * deploy happened (sitemap + IndexNow). `notify` is the former top-level
1561
+ * `vitops indexing`.
1562
+ */
1563
+ async function cmdSearch(argv) {
1564
+ const [sub, ...rest] = argv;
1565
+ switch (sub) {
1566
+ case "setup": return cmdSearchSetup(rest);
1567
+ case "notify": return cmdSearchNotify(rest);
1568
+ case void 0:
1569
+ case "-h":
1570
+ case "--help":
1571
+ console.log(SEARCH_HELP);
1572
+ return;
1573
+ default: fail(`unknown "search" subcommand "${sub}" (expected: setup | notify). Try: vitops --help`);
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Onboard domains into Google Search Console as domain properties.
1578
+ *
1579
+ * Idempotent by construction: the planner decides each step from the domain's live
1580
+ * state, so a re-run of a fully-onboarded domain is all skips. `--check` reports
1581
+ * drift and exits non-zero without mutating; `--dry` prints the plan and stops.
1582
+ * DNS is only ever *created* — the verification TXT — never edited or removed.
1583
+ *
1584
+ * Search Console has no user/permission API, so granting a Google Group Full-User
1585
+ * access stays manual and is surfaced as a reminder, not attempted.
1586
+ */
1587
+ async function cmdSearchSetup(argv) {
1588
+ const { values } = parseArgs({
1589
+ args: argv,
1590
+ options: {
1591
+ input: {
1592
+ type: "string",
1593
+ short: "i",
1594
+ default: "site.json"
1595
+ },
1596
+ "site-env": {
1597
+ type: "string",
1598
+ default: "production"
1599
+ },
1600
+ domain: { type: "string" },
1601
+ check: {
1602
+ type: "boolean",
1603
+ default: false
1604
+ },
1605
+ dry: {
1606
+ type: "boolean",
1607
+ default: false
1608
+ }
1609
+ },
1610
+ allowPositionals: false
1611
+ });
1612
+ const domains = toSearchConsoleSetup(await loadConfig(resolve(values.input), values["site-env"]), values.domain);
1613
+ if (domains.length === 0) fail("no domains in site.searchConsole — add domains to onboard (keyed by bare hostname)");
1614
+ const cfToken = loadCloudflareToken();
1615
+ const oauth = loadGoogleOAuth();
1616
+ if (!cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
1617
+ if (!oauth) fail("set VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN — a user OAuth token scoped to siteverification + webmasters");
1618
+ const token = await refreshTokenGrant(oauth);
1619
+ const states = /* @__PURE__ */ new Map();
1620
+ const verifyTokens = /* @__PURE__ */ new Map();
1621
+ const webIds = /* @__PURE__ */ new Map();
1622
+ for (const d of domains) {
1623
+ const zone = await findZoneId(cfToken, d.domain);
1624
+ if (!zone.ok || !zone.zoneId) fail(`${d.domain}: ${zone.message}`);
1625
+ const vt = await getVerificationToken(token, d.domain);
1626
+ if (!vt.ok || !vt.token) fail(`${d.domain}: could not obtain a verification token — ${vt.message}`);
1627
+ verifyTokens.set(d.domain, vt.token);
1628
+ const txt = await listApexTxt(cfToken, zone.zoneId, d.domain);
1629
+ if (!txt.ok) fail(`${d.domain}: ${txt.message}`);
1630
+ const wr = await getWebResource(token, d.domain);
1631
+ if (!wr.ok) fail(`${d.domain}: ${wr.message}`);
1632
+ if (wr.id) webIds.set(d.domain, wr.id);
1633
+ const site = await getSite(token, siteUrlFor(d.domain));
1634
+ if (!site.ok) fail(`${d.domain}: ${site.message}`);
1635
+ states.set(d.domain, {
1636
+ zoneId: zone.zoneId,
1637
+ txtPresent: txt.contents.includes(vt.token),
1638
+ verified: wr.exists,
1639
+ currentOwners: wr.owners,
1640
+ propertyExists: site.exists
1641
+ });
1642
+ }
1643
+ const p = plan$1({ domains }, states);
1644
+ console.log(formatPlan$1(p));
1645
+ if (values.check) {
1646
+ if (hasDrift(p)) {
1647
+ console.error("\n✖ drift — one or more domains are not fully onboarded");
1648
+ process.exit(1);
1649
+ }
1650
+ console.log("\n✓ every domain is onboarded");
1651
+ return;
1652
+ }
1653
+ if (values.dry) {
1654
+ console.log("\n(--dry: nothing was changed)");
1655
+ return;
1656
+ }
1657
+ const results = [];
1658
+ let failed = false;
1659
+ const delays = backoffSchedule();
1660
+ for (const d of domains) {
1661
+ const dp = p.domains.find((x) => x.domain === d.domain);
1662
+ const state = states.get(d.domain);
1663
+ const result = {
1664
+ domain: d.domain,
1665
+ txt: "—",
1666
+ verified: "—",
1667
+ property: "—",
1668
+ reminders: dp.reminders
1669
+ };
1670
+ if (dp.txt.action === "skip") result.txt = "present";
1671
+ else {
1672
+ const r = await createApexTxt(cfToken, state.zoneId, d.domain, verifyTokens.get(d.domain));
1673
+ if (!r.ok) {
1674
+ console.error(`✖ ${d.domain} TXT: ${r.message}`);
1675
+ result.txt = "failed";
1676
+ failed = true;
1677
+ results.push(result);
1678
+ continue;
1679
+ }
1680
+ result.txt = "created";
1681
+ }
1682
+ let verified = state.verified;
1683
+ if (verified) result.verified = "yes";
1684
+ else {
1685
+ for (let attempt = 0; attempt < delays.length; attempt++) {
1686
+ const v = await verifyWebResource(token, d.domain);
1687
+ if (v.ok) {
1688
+ verified = true;
1689
+ if (v.id) webIds.set(d.domain, v.id);
1690
+ state.currentOwners = v.owners;
1691
+ break;
1692
+ }
1693
+ if (attempt < delays.length - 1) {
1694
+ const wait = delays[attempt];
1695
+ console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
1696
+ await sleep(wait);
1697
+ }
1698
+ }
1699
+ result.verified = verified ? "yes" : "pending";
1700
+ }
1701
+ if (!verified) {
1702
+ results.push(result);
1703
+ continue;
1704
+ }
1705
+ if (dp.property.action === "skip") result.property = "present";
1706
+ else {
1707
+ const r = await addSite(token, dp.siteUrl);
1708
+ if (!r.ok) {
1709
+ console.error(`✖ ${d.domain} property: ${r.message}`);
1710
+ result.property = "failed";
1711
+ failed = true;
1712
+ } else result.property = "added";
1713
+ }
1714
+ if (dp.owners.action === "update" && dp.ownersToAdd.length) {
1715
+ const id = webIds.get(d.domain);
1716
+ if (!id) {
1717
+ console.error(`✖ ${d.domain} owners: no web-resource id (verify may be too fresh)`);
1718
+ failed = true;
1719
+ } else {
1720
+ const r = await updateOwners(token, id, d.domain, ownerUnion(state.currentOwners, dp.ownersToAdd));
1721
+ if (!r.ok) {
1722
+ console.error(`✖ ${d.domain} owners: ${r.message}`);
1723
+ failed = true;
1724
+ } else console.log(`✓ ${d.domain}: added ${dp.ownersToAdd.length} owner(s)`);
1725
+ }
1726
+ }
1727
+ results.push(result);
1728
+ }
1729
+ console.log(`\n${formatSummary(results)}`);
1730
+ if (failed) process.exit(1);
1413
1731
  }
1414
1732
  function cliVersion() {
1415
1733
  try {
@@ -1448,7 +1766,7 @@ function cmdDocs(argv) {
1448
1766
  return;
1449
1767
  }
1450
1768
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
1451
- const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
1769
+ const docs = generateDocs(loadDesignSystem(resolve(values.input), values.theme), generatorAssetsDir());
1452
1770
  const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
1453
1771
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
1454
1772
  }
@@ -1481,7 +1799,7 @@ function cmdLint(argv) {
1481
1799
  });
1482
1800
  const format = values.format;
1483
1801
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
1484
- const ds = loadConfig(resolve(values.input), values.theme);
1802
+ const ds = loadDesignSystem(resolve(values.input), values.theme);
1485
1803
  const srcDir = resolve(values.src);
1486
1804
  if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
1487
1805
  const palette = expandPalette(ds.colors.palette);
@@ -1534,7 +1852,7 @@ function cmdAgents(argv) {
1534
1852
  });
1535
1853
  const inputRel = values.input;
1536
1854
  let ds = null;
1537
- if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
1855
+ if (existsSync(resolve(inputRel))) ds = loadDesignSystem(resolve(inputRel), values.theme);
1538
1856
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
1539
1857
  const legacyDocsDir = values["docs-dir"];
1540
1858
  let summary;
@@ -1636,9 +1954,9 @@ async function main() {
1636
1954
  case "lint": return cmdLint(rest);
1637
1955
  case "legal": return cmdLegal(rest);
1638
1956
  case "icons": return cmdIcons(rest);
1639
- case "indexing": return cmdIndexing(rest);
1957
+ case "search": return cmdSearch(rest);
1640
1958
  case "media": return cmdMedia(rest);
1641
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | indexing | media). Try: vitops --help`);
1959
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | media). Try: vitops --help`);
1642
1960
  }
1643
1961
  }
1644
1962
  main().catch((err) => fail(err.message));