@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/CHANGELOG.md +205 -0
- package/README.md +1 -1
- package/dist/cli.mjs +358 -40
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +105 -17
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,
|
|
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
|
/**
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
675
|
-
|
|
676
|
-
|
|
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 (
|
|
864
|
+
if (isConfig(raw)) {
|
|
808
865
|
let site;
|
|
809
866
|
try {
|
|
810
|
-
site =
|
|
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
|
-
* `
|
|
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$
|
|
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
|
|
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. `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 "
|
|
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 |
|
|
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));
|