@getvitops/cli 3.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,125 @@
1
1
  # @getvitops/cli
2
2
 
3
+ ## 4.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - f7bc0a0: Add `vitops search setup` — onboard domains into Google Search Console as domain properties.
8
+
9
+ **Breaking:** the `vitops indexing` command has been renamed to **`vitops search notify`**. There is
10
+ no alias — update any scripts or CI that call `vitops indexing` to `vitops search notify` (same flags,
11
+ same behaviour).
12
+
13
+ **New: `vitops search setup`.** For each domain in a site config's new `site.searchConsole` block
14
+ (a record keyed by bare hostname), it:
15
+ - ensures the apex verification TXT record in Cloudflare,
16
+ - verifies ownership via the Site Verification API (DNS_TXT), retried with exponential backoff while
17
+ DNS propagates — a still-unverified domain is reported **PENDING**, not failed,
18
+ - adds the `sc-domain:` property via the Search Console API,
19
+ - adds any `delegatedOwners` to the verified Site Verification web resource.
20
+
21
+ It is idempotent (a re-run of an onboarded domain is a no-op), supports `--check` (report drift, exit
22
+ non-zero, mutate nothing) and `--dry` (print the plan, change nothing), and `--domain <name>` to
23
+ scope to one entry. DNS records are only ever created, never edited or deleted.
24
+
25
+ Credentials come from the environment, never the config: `CLOUDFLARE_API_TOKEN` (a `Zone:DNS:Edit`
26
+ token) and a Google **user OAuth refresh token** as `VITOPS_GOOGLE_CLIENT_ID` /
27
+ `VITOPS_GOOGLE_CLIENT_SECRET` / `VITOPS_GOOGLE_REFRESH_TOKEN` (scoped to `siteverification` +
28
+ `webmasters`). Granting a Google Group **Full-User** access has no Search Console API and is surfaced
29
+ as a reminder in the summary rather than automated.
30
+
31
+ - ceed51f: `vitops search notify` now accepts either Google credential, so most sites need only one.
32
+
33
+ The two halves of `vitops search` each demanded a different, unrelated Google setup — five environment variables between them. `search setup` requires a user OAuth credential (verifying a site makes the caller an **owner** of the property, and that should be a person, not a project robot), while `search notify` accepted only a service account. Search Console does not care which identity calls it, so a consumer who had already run `search setup` was being made to create a second credential for the other half of the same command.
34
+
35
+ `search notify` now takes whichever you have, preferring the service account when both are set — it runs on every deploy, in CI, and a service account does not expire, whereas a refresh token can be revoked and expires after 7 days for an OAuth client still in _Testing_ publishing status. `search setup` is unchanged and still requires user OAuth.
36
+
37
+ **Breaking — two renamed exports.** Both did the same job under the same name from different subpaths, which forced an alias at every call site:
38
+ - `@getvitops/utils/indexing`: `getAccessToken` → **`serviceAccountToken`**
39
+ - `@getvitops/utils/onboarding`: `getAccessToken` → **`refreshTokenGrant`**
40
+
41
+ New: `googleAccessToken(credential)` from `@getvitops/utils/indexing` takes a `GoogleCredential` union and is what both wrappers now call. If you were importing either `getAccessToken`, switch to the specific name, or to `googleAccessToken` if you want to accept either identity.
42
+
43
+ Internally the exchange is now one function. The two grants send different form bodies; everything after that — endpoint, content-type, error handling, `access_token` extraction — was duplicated verbatim in two modules. The service-account JWT signing had no test at all, because it was only reachable over the network; it now does, including the literal-`\n`-in-a-PEM case that secret stores produce.
44
+
45
+ ### Minor Changes
46
+
47
+ - 14813fa: Document which tier provides each pattern — `vitops docs components`.
48
+
49
+ The toolkit ships four tiers that compose: CSS framework classes, `<wc-*>` web components, Astro
50
+ components, Bricks elements. Nothing said so. The class reference listed pattern names, the
51
+ elements reference listed Bricks controls, and the fact that `tree` is _also_ a web component
52
+ _and_ an Astro component existed only in the source layout. The cost is silent: you hand-write
53
+ the markup a component already emits, or you wrap a component that already emits its own tag.
54
+
55
+ `TIERS` in `@getvitops/generator` is the new manifest — per pattern, its CSS partial and
56
+ representative classes, its `wc-*` tag and which bundle that ships in, its Astro component(s),
57
+ its Bricks element, and one line saying what to actually write. `tierPatterns(tier)` projects one
58
+ tier. `vitops docs components` renders all four in one document, and the reference sections it
59
+ feeds render the same data per tier.
60
+
61
+ It is **authored rather than derived, and drift-guarded** instead. Naming convention carries most
62
+ of it and breaks exactly where it matters — `splitter.css` hosts two components, `tag.css` serves
63
+ three config patterns, `anchor-link.css` provides `.link`, `layout.css` provides `.split` and
64
+ `.centered` — so a derivation would need an exceptions table longer than the rule, and a rename
65
+ would silently drop a link. Instead, tests fail the build when a hand-written CSS partial, a
66
+ `customElements.define`, an exported Astro component or a Bricks element isn't accounted for.
67
+
68
+ Four things the manifest records that cannot be inferred, each a real failure mode:
69
+ - **Whether an Astro component wraps a web component.** `Tree` and `CookieConsent` emit the
70
+ `<wc-*>` tag with the fallback inside; `Details`, `Drawer`, `Popover`, `NavShell` and `Subgrid`
71
+ emit tier-1 markup with no web component at all. Reading the first group as the second gives
72
+ `<wc-tree><Tree /></wc-tree>` — two elements on one tree, which renders and misbehaves.
73
+ - **Where each element ships.** `elements.js` for the registered set, its own bundle for
74
+ `<wc-consent>` and `<wc-theme-editor>`, and **no bundle at all** for the four editor-v2 tags —
75
+ which are registered, importable-looking and inert in a consumer project. The guard checks this
76
+ against what `js/elements.ts` imports, so it can't claim an element ships when it doesn't.
77
+ - **Whether a pattern is config-authored.** `patterns.items` patterns get the token cascade,
78
+ states, role variants and override hooks; a structural partial gets none of them. The docs also
79
+ now say when a config-authored pattern is **absent from your config**, because naming its
80
+ classes while your build emits none of them is worse than omitting it.
81
+ - **The import specifier, verbatim.** These are published as the line to copy, so they are checked
82
+ against `@getvitops/astro`'s `exports`. That guard immediately caught
83
+ `@getvitops/astro/components/../CookieConsent.astro` — plausible-looking, right basename, not an
84
+ importable path (`CookieConsent.astro` ships from the package root).
85
+
86
+ - 14813fa: Document the consent, conversion-tracking, search and legal subsystems for agents.
87
+
88
+ `vitops docs` gains four topics — `consent`, `tracking`, `search`, `legal` — and the docs
89
+ bundle gains the matching concept docs under `concepts/`. Until now these subsystems were
90
+ described only by their generated config-schema field descriptions, so an agent working in a
91
+ consumer project had no account of the rules that make them work, and every one of those rules
92
+ fails **silently** when broken:
93
+ - a gated tag given a live `src` instead of `type="text/plain"` fetches the third party anyway,
94
+ so the gate becomes decorative;
95
+ - a caller using `granted()` instead of `require()` never raises the banner, so on a site where
96
+ nothing else demands that category the permission is never offered, never granted, and the
97
+ write never happens;
98
+ - an absent `window.vitopsConsent` read as "denied" rather than "this site has no gate";
99
+ - a consent patch widened to every category records consent nobody gave;
100
+ - a sitemap with no `<lastmod>` makes edited pages undetectable, so `search notify` looks
101
+ healthy while never resubmitting anything;
102
+ - a stale IndexNow key file returns `202` and is then discarded;
103
+ - Google's Indexing API accepts ordinary URLs, discards them, and violates its own terms.
104
+
105
+ The packaged agent skill now covers consent and conversion tracking, and its description names
106
+ these subsystems so it is actually surfaced when they are the task.
107
+
108
+ Also fixed: the icons concept doc still called the Astro integration `getvitops()`, renamed to
109
+ `vitops()` in 3.0.
110
+
111
+ ### Patch Changes
112
+
113
+ - Updated dependencies [14813fa]
114
+ - Updated dependencies [c6b99e7]
115
+ - Updated dependencies [c6b99e7]
116
+ - Updated dependencies [f7bc0a0]
117
+ - Updated dependencies [ceed51f]
118
+ - Updated dependencies [14813fa]
119
+ - Updated dependencies [14813fa]
120
+ - @getvitops/generator@4.0.0
121
+ - @getvitops/utils@4.0.0
122
+
3
123
  ## 3.0.0
4
124
 
5
125
  ### Major Changes
package/README.md CHANGED
@@ -25,7 +25,7 @@ npx vitops generate --format bricks,css --out dist
25
25
  | `vitops favicon -i <svg\|png> -o <dir>` | generate a favicon set from a source image |
26
26
  | `vitops agents [-o AGENTS.md] [--docs-dir <dir>]` | link the agent skill + AGENTS.md pointer |
27
27
  | `vitops docs [topic] [--all]` | print live reference docs to stdout |
28
- | `vitops indexing [--dry] [--check]` | tell search engines about a deploy |
28
+ | `vitops search notify [--dry] [--check]` | tell search engines about a deploy |
29
29
  | `vitops media [--raw <dir>] [-o <dir>]` | encode raw video to WebM + MP4 + poster |
30
30
 
31
31
  Every `--input` takes a `design-system.json` **or** the larger site config that embeds one
package/dist/cli.mjs CHANGED
@@ -6,8 +6,9 @@ import { dirname, join, parse, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
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
  /**
@@ -54,6 +55,26 @@ const TOPICS = {
54
55
  path: "concepts/patterns.md",
55
56
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
56
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
+ },
57
78
  elements: {
58
79
  path: "bricks/elements.md",
59
80
  summary: "Bricks Builder custom elements, controls, defaults"
@@ -536,7 +557,7 @@ function lintSource(files, v, format) {
536
557
  * vitops docs [topic] [--input <json>] [--all]
537
558
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
538
559
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
539
- * vitops indexing [--input <site.json>] [--dry] [--all] [--check]
560
+ * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
540
561
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
541
562
  *
542
563
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
@@ -580,7 +601,7 @@ Usage:
580
601
  vitops lint [options] Report framework classes in your source that resolve to nothing
581
602
  vitops legal [options] Render legal documents from a site config
582
603
  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)
604
+ vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
584
605
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
585
606
 
586
607
  Generate options:
@@ -654,7 +675,29 @@ Legal options:
654
675
  Generated from your config — a starting point, not legal advice. Review before
655
676
  publishing, and make sure the config describes what your site actually does.
656
677
 
657
- 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:
658
701
  -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
659
702
  --site-env <env> Environment to notify for (default: production). An
660
703
  environment whose robots policy says noindex is refused.
@@ -671,13 +714,16 @@ Indexing options:
671
714
  Persist it between runs (a CI cache) or every run submits
672
715
  everything.
673
716
 
674
- 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
675
718
  sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
676
719
  through the Search Console API and verifies the result with --check. IndexNow
677
720
  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.
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.
681
727
 
682
728
  Media options:
683
729
  --raw <dir> Directory of unprocessed video, walked recursively
@@ -707,6 +753,13 @@ Media options:
707
753
  Common:
708
754
  -h, --help Show this help
709
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
+ `;
710
763
  function fail(msg) {
711
764
  console.error(`✖ ${msg}`);
712
765
  process.exit(1);
@@ -959,7 +1012,7 @@ async function cmdMedia(argv) {
959
1012
  onProgress: (message) => console.error(` … ${message}`)
960
1013
  });
961
1014
  if (values.dry) {
962
- console.log(formatPlan$1(result.plan));
1015
+ console.log(formatPlan$2(result.plan));
963
1016
  return;
964
1017
  }
965
1018
  console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
@@ -1275,7 +1328,7 @@ const readSitemap = async (source) => {
1275
1328
  * The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
1276
1329
  * `gsc.ts`.
1277
1330
  */
1278
- async function cmdIndexing(argv) {
1331
+ async function cmdSearchNotify(argv) {
1279
1332
  const { values } = parseArgs({
1280
1333
  args: argv,
1281
1334
  options: {
@@ -1318,7 +1371,7 @@ async function cmdIndexing(argv) {
1318
1371
  const key = newKey();
1319
1372
  console.log(key);
1320
1373
  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\`.`);
1374
+ console.log(` Then serve it at /${key}.txt — \`vitops search notify --write-key public\`.`);
1322
1375
  return;
1323
1376
  }
1324
1377
  const config = toIndexingConfig(await loadConfig(resolve(values.input), values["site-env"]), values["site-env"]);
@@ -1354,13 +1407,13 @@ async function cmdIndexing(argv) {
1354
1407
  console.log("\n(--dry: nothing was submitted)");
1355
1408
  return;
1356
1409
  }
1357
- const account = loadServiceAccount();
1410
+ const credential = loadSearchCredential();
1358
1411
  let failed = false;
1359
1412
  if (values.check) {
1360
1413
  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");
1414
+ if (!credential) fail(`--check needs a Search Console credential — ${CREDENTIAL_HINT}`);
1362
1415
  if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
1363
- const token = await getAccessToken(account);
1416
+ const token = await googleAccessToken(credential);
1364
1417
  console.log(`\nInspecting ${p.check.length} priority URL(s):`);
1365
1418
  for (const url of p.check) {
1366
1419
  const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
@@ -1384,7 +1437,7 @@ async function cmdIndexing(argv) {
1384
1437
  const check = await verifyKeyFile(keyLocation, key);
1385
1438
  if (!check.ok) {
1386
1439
  console.error(`✖ IndexNow key file check failed — ${check.reason}`);
1387
- console.error(` Write it with: vitops indexing --write-key <public dir>`);
1440
+ console.error(` Write it with: vitops search notify --write-key <public dir>`);
1388
1441
  failed = true;
1389
1442
  } else {
1390
1443
  let sent = 0;
@@ -1400,9 +1453,9 @@ async function cmdIndexing(argv) {
1400
1453
  if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
1401
1454
  }
1402
1455
  }
1403
- 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})`);
1404
1457
  else {
1405
- const r = await submitSitemap(await getAccessToken(account), p.searchConsole.siteUrl, sitemapUrl);
1458
+ const r = await submitSitemap(await googleAccessToken(credential), p.searchConsole.siteUrl, sitemapUrl);
1406
1459
  if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1407
1460
  else {
1408
1461
  console.error(`✖ Search Console: ${r.message}`);
@@ -1414,7 +1467,267 @@ async function cmdIndexing(argv) {
1414
1467
  writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
1415
1468
  console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
1416
1469
  }
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.`);
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);
1418
1731
  }
1419
1732
  function cliVersion() {
1420
1733
  try {
@@ -1641,9 +1954,9 @@ async function main() {
1641
1954
  case "lint": return cmdLint(rest);
1642
1955
  case "legal": return cmdLegal(rest);
1643
1956
  case "icons": return cmdIcons(rest);
1644
- case "indexing": return cmdIndexing(rest);
1957
+ case "search": return cmdSearch(rest);
1645
1958
  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`);
1959
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | media). Try: vitops --help`);
1647
1960
  }
1648
1961
  }
1649
1962
  main().catch((err) => fail(err.message));