@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 +120 -0
- package/README.md +1 -1
- package/dist/cli.mjs +334 -21
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +105 -18
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
|
|
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,
|
|
10
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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 "
|
|
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 |
|
|
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));
|