@getvitops/cli 3.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +270 -0
- package/README.md +1 -1
- package/dist/cli.mjs +857 -22
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +111 -19
package/dist/cli.mjs
CHANGED
|
@@ -4,10 +4,14 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, join, parse, relative, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
-
import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
|
|
7
|
+
import { JURISDICTION_COUNTRIES, NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, processorsMissingLocation, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
|
|
8
8
|
import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
|
|
9
|
-
import { SNAPSHOT_PATH, collectEntries, formatPlan,
|
|
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 { AD_PLATFORMS, AD_PROVIDER_KEYS, formatPlan as formatPlan$2, formatSummary as formatSummary$1, hasDrift as hasDrift$1, isAdProvider, missingFields, plan as plan$2, renderTag, tagId } from "@getvitops/utils/ads";
|
|
12
|
+
import { PLATFORM_PARAMS } from "@getvitops/utils/tracking";
|
|
13
|
+
import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$3, processMedia } from "@getvitops/utils/media";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
11
15
|
|
|
12
16
|
//#region src/agents.ts
|
|
13
17
|
/**
|
|
@@ -54,6 +58,26 @@ const TOPICS = {
|
|
|
54
58
|
path: "concepts/patterns.md",
|
|
55
59
|
summary: "pattern CSS: token cascade, override hooks, states, role variants"
|
|
56
60
|
},
|
|
61
|
+
components: {
|
|
62
|
+
path: "concepts/components.md",
|
|
63
|
+
summary: "which of the four tiers provides each pattern, and the call to make"
|
|
64
|
+
},
|
|
65
|
+
consent: {
|
|
66
|
+
path: "concepts/consent.md",
|
|
67
|
+
summary: "the consent gate: inert gated tags, demand-driven prompting, three-valued choices"
|
|
68
|
+
},
|
|
69
|
+
tracking: {
|
|
70
|
+
path: "concepts/tracking.md",
|
|
71
|
+
summary: "ad-click attribution, the `_ac` cookie, conversion notifications"
|
|
72
|
+
},
|
|
73
|
+
search: {
|
|
74
|
+
path: "concepts/search.md",
|
|
75
|
+
summary: "what search engines accept, Search Console onboarding, deploy notification"
|
|
76
|
+
},
|
|
77
|
+
legal: {
|
|
78
|
+
path: "concepts/legal.md",
|
|
79
|
+
summary: "privacy policy / terms / cookie notice derived from config facts"
|
|
80
|
+
},
|
|
57
81
|
elements: {
|
|
58
82
|
path: "bricks/elements.md",
|
|
59
83
|
summary: "Bricks Builder custom elements, controls, defaults"
|
|
@@ -105,6 +129,144 @@ function linkSkill(linkPath, targetDir) {
|
|
|
105
129
|
}
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/prompt.ts
|
|
134
|
+
/**
|
|
135
|
+
* Asking for the config values `vitops ads setup` cannot derive, and writing the
|
|
136
|
+
* answers back.
|
|
137
|
+
*
|
|
138
|
+
* The realistic first run has no verification token in the config, because the
|
|
139
|
+
* token does not exist until someone opens the platform UI and asks for one. That
|
|
140
|
+
* makes a missing token a **question**, not an error — but only when there is
|
|
141
|
+
* somebody there to answer it, which is why every decision about *whether* to ask
|
|
142
|
+
* is made here and the planner stays pure.
|
|
143
|
+
*
|
|
144
|
+
* The split mirrors the rest of the toolchain: `questionFor` and `patchRaw` are
|
|
145
|
+
* pure and tested; `ask` and `writeConfigPatch` are the I/O, and they decide
|
|
146
|
+
* nothing.
|
|
147
|
+
*
|
|
148
|
+
* Two rules hold the whole file together:
|
|
149
|
+
*
|
|
150
|
+
* - **A prompt must never hang a CI run.** Asking requires a TTY and an explicit
|
|
151
|
+
* absence of `--dry` / `--check` / `--no-prompt`; otherwise the caller gets the
|
|
152
|
+
* same named error it would have got before this file existed.
|
|
153
|
+
* - **A fact about the site goes in the config; a credential stays in the
|
|
154
|
+
* environment.** A verification token is published in DNS — the platform
|
|
155
|
+
* fetching it back is the ownership proof — so persisting it is right, and it is
|
|
156
|
+
* the only thing here that gets written. `CLOUDFLARE_API_TOKEN` may be typed at
|
|
157
|
+
* a prompt and lives only in the process.
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* What to ask for one gap, and where the operator finds it.
|
|
161
|
+
*
|
|
162
|
+
* The hints are the point. "meta.domainVerification is missing" is true and
|
|
163
|
+
* useless; the UI path plus the console URL is the difference between a prompt
|
|
164
|
+
* someone can answer now and one they abandon to go searching.
|
|
165
|
+
*/
|
|
166
|
+
function questionFor(provider, needs) {
|
|
167
|
+
const platform = AD_PLATFORMS[provider];
|
|
168
|
+
if (needs === "domainVerification") return {
|
|
169
|
+
provider,
|
|
170
|
+
needs,
|
|
171
|
+
title: `${platform.name} — domain verification token`,
|
|
172
|
+
hints: [...platform.verification.where ? [platform.verification.where] : [], platform.consoleUrl],
|
|
173
|
+
label: platform.verification.txtPrefix ?? "token"
|
|
174
|
+
};
|
|
175
|
+
const what = needs === "pixelId" ? provider === "google" ? "conversion ID (AW-…, not the customer ID)" : "pixel / tag ID" : "advertising account ID";
|
|
176
|
+
return {
|
|
177
|
+
provider,
|
|
178
|
+
needs,
|
|
179
|
+
title: `${platform.name} — ${what}`,
|
|
180
|
+
hints: [platform.consoleUrl],
|
|
181
|
+
label: needs
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** The error a non-interactive run gets instead of a prompt. */
|
|
185
|
+
function missingFieldMessage(provider, needs) {
|
|
186
|
+
const q = questionFor(provider, needs);
|
|
187
|
+
return `site.ads.${provider}.${needs} is not set — ${q.title}.\n` + q.hints.map((h) => ` ${h}`).join("\n") + `\n Run this interactively to be prompted for it, or add it to the config yourself.`;
|
|
188
|
+
}
|
|
189
|
+
/** Whether this run is allowed to ask. Everything that would hang CI is checked here. */
|
|
190
|
+
function canPrompt(opts) {
|
|
191
|
+
if (opts.dry || opts.check || opts.noPrompt) return false;
|
|
192
|
+
return opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Ask one question. An empty answer means "skip this one" — reported as still
|
|
196
|
+
* blocked rather than treated as a value, so nothing is written and nothing is
|
|
197
|
+
* created on the strength of a stray Enter.
|
|
198
|
+
*/
|
|
199
|
+
async function ask(q) {
|
|
200
|
+
const rl = createInterface({
|
|
201
|
+
input: process.stdin,
|
|
202
|
+
output: process.stdout
|
|
203
|
+
});
|
|
204
|
+
try {
|
|
205
|
+
console.log(`\n${q.title}`);
|
|
206
|
+
for (const hint of q.hints) console.log(` ${hint}`);
|
|
207
|
+
return (await rl.question(` ${q.label} `)).trim() || void 0;
|
|
208
|
+
} finally {
|
|
209
|
+
rl.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Merge one ad-property field into a **raw, on-disk** config object.
|
|
214
|
+
*
|
|
215
|
+
* Raw is load-bearing, and it is the rule `@getvitops/vite`'s `designSystemPath()`
|
|
216
|
+
* already follows for the theme editor's save: `resolveConfig` normalises shorthand
|
|
217
|
+
* shapes and resolves `extends` in memory, so writing from the resolved object
|
|
218
|
+
* would grow keys beside the author's and silently edit a copy nothing builds from.
|
|
219
|
+
* Everything outside `site.ads.<provider>.<field>` is left exactly as it was.
|
|
220
|
+
*/
|
|
221
|
+
function patchRaw(raw, provider, field, value) {
|
|
222
|
+
if (typeof raw !== "object" || raw == null || Array.isArray(raw)) throw new Error("config is not an object");
|
|
223
|
+
const root = raw;
|
|
224
|
+
const site = root["site"] ?? {};
|
|
225
|
+
const ads = site["ads"] ?? {};
|
|
226
|
+
const entry = ads[provider] ?? {};
|
|
227
|
+
return {
|
|
228
|
+
...root,
|
|
229
|
+
site: {
|
|
230
|
+
...site,
|
|
231
|
+
ads: {
|
|
232
|
+
...ads,
|
|
233
|
+
[provider]: {
|
|
234
|
+
...entry,
|
|
235
|
+
[field]: value
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Indentation the file already uses, so a patch doesn't reformat the whole document. */
|
|
242
|
+
function detectIndent(source) {
|
|
243
|
+
const match = /\n([ \t]+)"/.exec(source);
|
|
244
|
+
if (!match?.[1]) return 2;
|
|
245
|
+
return match[1].includes(" ") ? " " : match[1].length;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Persist one answered field into the config file.
|
|
249
|
+
*
|
|
250
|
+
* JSON only: a `.js`/`.ts` config is code, and rewriting code to insert a value is
|
|
251
|
+
* a different (and much worse) problem than merging an object — so the caller is
|
|
252
|
+
* told to paste it instead. Returns whether it wrote.
|
|
253
|
+
*/
|
|
254
|
+
function writeConfigPatch(path, provider, field, value) {
|
|
255
|
+
if (!path.endsWith(".json")) return {
|
|
256
|
+
written: false,
|
|
257
|
+
reason: `${path} is a module, not JSON — add "${field}": "${value}" to site.ads.${provider} yourself`
|
|
258
|
+
};
|
|
259
|
+
if (!existsSync(path)) return {
|
|
260
|
+
written: false,
|
|
261
|
+
reason: `${path} no longer exists`
|
|
262
|
+
};
|
|
263
|
+
const source = readFileSync(path, "utf8");
|
|
264
|
+
const next = patchRaw(JSON.parse(source), provider, field, value);
|
|
265
|
+
const trailingNewline = source.endsWith("\n") ? "\n" : "";
|
|
266
|
+
writeFileSync(path, JSON.stringify(next, null, detectIndent(source)) + trailingNewline);
|
|
267
|
+
return { written: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
108
270
|
//#endregion
|
|
109
271
|
//#region src/css-scan.ts
|
|
110
272
|
/** Strip comments, preserving newlines so line numbers survive. */
|
|
@@ -536,7 +698,8 @@ function lintSource(files, v, format) {
|
|
|
536
698
|
* vitops docs [topic] [--input <json>] [--all]
|
|
537
699
|
* vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
|
|
538
700
|
* vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
|
|
539
|
-
* vitops
|
|
701
|
+
* vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
|
|
702
|
+
* vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
|
|
540
703
|
* vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
|
|
541
704
|
*
|
|
542
705
|
* Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
|
|
@@ -580,7 +743,8 @@ Usage:
|
|
|
580
743
|
vitops lint [options] Report framework classes in your source that resolve to nothing
|
|
581
744
|
vitops legal [options] Render legal documents from a site config
|
|
582
745
|
vitops icons [options] Report which icons your source uses, and build the sprite
|
|
583
|
-
vitops
|
|
746
|
+
vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
|
|
747
|
+
vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
|
|
584
748
|
vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
|
|
585
749
|
|
|
586
750
|
Generate options:
|
|
@@ -654,7 +818,29 @@ Legal options:
|
|
|
654
818
|
Generated from your config — a starting point, not legal advice. Review before
|
|
655
819
|
publishing, and make sure the config describes what your site actually does.
|
|
656
820
|
|
|
657
|
-
|
|
821
|
+
Search subcommands:
|
|
822
|
+
vitops search setup [opts] Onboard site.searchConsole domains into GSC
|
|
823
|
+
vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
|
|
824
|
+
|
|
825
|
+
Search setup options:
|
|
826
|
+
-i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
|
|
827
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
828
|
+
--domain <name> Scope to a single site.searchConsole entry
|
|
829
|
+
--dry Print the plan and exit. Changes nothing.
|
|
830
|
+
--check Report drift and exit non-zero if any domain is not fully
|
|
831
|
+
onboarded. Mutates nothing.
|
|
832
|
+
|
|
833
|
+
For each domain it ensures the apex verification TXT in Cloudflare, verifies
|
|
834
|
+
ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
|
|
835
|
+
unverified domain is reported PENDING, not failed), adds the sc-domain: property,
|
|
836
|
+
and adds any delegatedOwners to the web resource. DNS is only ever created,
|
|
837
|
+
never edited or deleted. Credentials come from the environment:
|
|
838
|
+
CLOUDFLARE_API_TOKEN (Zone:DNS:Edit), and VITOPS_GOOGLE_CLIENT_ID /
|
|
839
|
+
VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
|
|
840
|
+
scoped to siteverification + webmasters). Granting a Google Group Full-User
|
|
841
|
+
access has no API and is surfaced as a reminder.
|
|
842
|
+
|
|
843
|
+
Search notify options:
|
|
658
844
|
-i, --input <path> Site config carrying seo.indexing (default: ./site.json)
|
|
659
845
|
--site-env <env> Environment to notify for (default: production). An
|
|
660
846
|
environment whose robots policy says noindex is refused.
|
|
@@ -671,13 +857,63 @@ Indexing options:
|
|
|
671
857
|
Persist it between runs (a CI cache) or every run submits
|
|
672
858
|
everything.
|
|
673
859
|
|
|
674
|
-
What
|
|
860
|
+
What notify can and cannot do: Google exposes no "request indexing" API, and its
|
|
675
861
|
sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
|
|
676
862
|
through the Search Console API and verifies the result with --check. IndexNow
|
|
677
863
|
reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
|
|
678
|
-
Search Console
|
|
679
|
-
|
|
680
|
-
|
|
864
|
+
Search Console takes either credential, whichever you already have, added as an
|
|
865
|
+
owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
|
|
866
|
+
JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
|
|
867
|
+
credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
|
|
868
|
+
_REFRESH_TOKEN). The service account is preferred when both are set — this runs
|
|
869
|
+
on every deploy, and it does not expire.
|
|
870
|
+
|
|
871
|
+
Ads subcommands:
|
|
872
|
+
vitops ads setup [opts] Ensure each platform's domain-verification DNS record
|
|
873
|
+
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
874
|
+
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
875
|
+
|
|
876
|
+
Ads setup options:
|
|
877
|
+
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
878
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
879
|
+
--provider <name> Scope to one site.ads entry (google | meta | linkedin |
|
|
880
|
+
reddit | tiktok | microsoft | pinterest | snapchat)
|
|
881
|
+
--dry Print the plan and exit. Creates nothing — but it does READ
|
|
882
|
+
live DNS first, so it needs CLOUDFLARE_API_TOKEN.
|
|
883
|
+
--check Report drift and exit non-zero if any property is not
|
|
884
|
+
linked. Mutates nothing, prompts for nothing.
|
|
885
|
+
--no-prompt Never ask for a missing token; fail with the field name
|
|
886
|
+
instead (the default when stdin is not a TTY, e.g. in CI)
|
|
887
|
+
--no-write Don't persist an answered token back into the config
|
|
888
|
+
|
|
889
|
+
Four platforms verify a domain by DNS TXT — Meta, TikTok, Pinterest, Snapchat —
|
|
890
|
+
and that record is the one thing this creates for you (in Cloudflare, via
|
|
891
|
+
CLOUDFLARE_API_TOKEN; created only, never edited or deleted). Google Ads,
|
|
892
|
+
LinkedIn, Reddit and Microsoft Ads have no domain verification at all: linking
|
|
893
|
+
there is the tag and the account ID, and the run says so rather than skipping in
|
|
894
|
+
silence. No platform Marketing API is called — Meta's needs a system-user token
|
|
895
|
+
and Google's needs an approved developer token, so the final "Verify" click stays
|
|
896
|
+
a reminder. The verification token is not a secret (it is published in DNS, which
|
|
897
|
+
is the ownership proof), so it lives in the config and is prompted for on the
|
|
898
|
+
first run.
|
|
899
|
+
|
|
900
|
+
Ads tags options:
|
|
901
|
+
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
902
|
+
--provider <name> Print one platform's tag
|
|
903
|
+
--strategy <s> idle | async | interaction (default: idle)
|
|
904
|
+
|
|
905
|
+
Prints each pixel as an INERT, consent-gated <script>: type="text/plain" with the
|
|
906
|
+
library URL on data-src, so an undecided visitor's page issues no third-party
|
|
907
|
+
request. For stacks with no Astro integration — Bricks, WordPress, Eleventy.
|
|
908
|
+
Paste into your template; @getvitops/core's consent runtime activates them.
|
|
909
|
+
|
|
910
|
+
Ads lint options:
|
|
911
|
+
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
912
|
+
|
|
913
|
+
Reports the gaps that are invisible at runtime: a click-ID parameter this
|
|
914
|
+
platform stamps that attribution doesn't capture, a pixel while site.tracking is
|
|
915
|
+
off (every conversion arrives unattributed), and a configured property with no
|
|
916
|
+
tag ID. Exits non-zero on a finding.
|
|
681
917
|
|
|
682
918
|
Media options:
|
|
683
919
|
--raw <dir> Directory of unprocessed video, walked recursively
|
|
@@ -707,6 +943,21 @@ Media options:
|
|
|
707
943
|
Common:
|
|
708
944
|
-h, --help Show this help
|
|
709
945
|
`;
|
|
946
|
+
const SEARCH_HELP = `vitops search — Google Search Console
|
|
947
|
+
|
|
948
|
+
vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
|
|
949
|
+
vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
|
|
950
|
+
|
|
951
|
+
Run \`vitops --help\` for the full option list for each subcommand.
|
|
952
|
+
`;
|
|
953
|
+
const ADS_HELP = `vitops ads — link this site to its ad properties
|
|
954
|
+
|
|
955
|
+
vitops ads setup [opts] Ensure each platform's domain-verification DNS record
|
|
956
|
+
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
957
|
+
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
958
|
+
|
|
959
|
+
Run \`vitops --help\` for the full option list for each subcommand.
|
|
960
|
+
`;
|
|
710
961
|
function fail(msg) {
|
|
711
962
|
console.error(`✖ ${msg}`);
|
|
712
963
|
process.exit(1);
|
|
@@ -959,7 +1210,7 @@ async function cmdMedia(argv) {
|
|
|
959
1210
|
onProgress: (message) => console.error(` … ${message}`)
|
|
960
1211
|
});
|
|
961
1212
|
if (values.dry) {
|
|
962
|
-
console.log(formatPlan$
|
|
1213
|
+
console.log(formatPlan$3(result.plan));
|
|
963
1214
|
return;
|
|
964
1215
|
}
|
|
965
1216
|
console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
|
|
@@ -1202,6 +1453,15 @@ async function cmdLegal(argv) {
|
|
|
1202
1453
|
docs,
|
|
1203
1454
|
output
|
|
1204
1455
|
});
|
|
1456
|
+
/**
|
|
1457
|
+
* A processor placed nowhere cannot appear in the cross-border disclosure — there
|
|
1458
|
+
* is nothing true to say about it — so it drops out of that section silently,
|
|
1459
|
+
* which is the failure that looks tidy. Reported here rather than in the document:
|
|
1460
|
+
* a policy must not editorialise about its own gaps.
|
|
1461
|
+
*
|
|
1462
|
+
* On **stderr** because stdout is the document and gets piped or redirected.
|
|
1463
|
+
*/
|
|
1464
|
+
if (docs.includes("privacy")) for (const name of processorsMissingLocation(site)) console.error(` ! ${name}: no country, storage or operatorCountry declared — it will not appear in the cross-border transfer disclosure. Add one, or state storage: [{ country: "${JURISDICTION_COUNTRIES[site.site.legal?.jurisdiction ?? "ca"]}" }] to say it does not leave the country.`);
|
|
1205
1465
|
if (!values.out) {
|
|
1206
1466
|
process.stdout.write(Object.values(files).join("\n"));
|
|
1207
1467
|
return;
|
|
@@ -1275,7 +1535,7 @@ const readSitemap = async (source) => {
|
|
|
1275
1535
|
* The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
|
|
1276
1536
|
* `gsc.ts`.
|
|
1277
1537
|
*/
|
|
1278
|
-
async function
|
|
1538
|
+
async function cmdSearchNotify(argv) {
|
|
1279
1539
|
const { values } = parseArgs({
|
|
1280
1540
|
args: argv,
|
|
1281
1541
|
options: {
|
|
@@ -1318,7 +1578,7 @@ async function cmdIndexing(argv) {
|
|
|
1318
1578
|
const key = newKey();
|
|
1319
1579
|
console.log(key);
|
|
1320
1580
|
console.log(`\n Add to your site config:\n "seo": { "indexing": { "indexNow": { "key": "${key}" } } }`);
|
|
1321
|
-
console.log(` Then serve it at /${key}.txt — \`vitops
|
|
1581
|
+
console.log(` Then serve it at /${key}.txt — \`vitops search notify --write-key public\`.`);
|
|
1322
1582
|
return;
|
|
1323
1583
|
}
|
|
1324
1584
|
const config = toIndexingConfig(await loadConfig(resolve(values.input), values["site-env"]), values["site-env"]);
|
|
@@ -1354,13 +1614,13 @@ async function cmdIndexing(argv) {
|
|
|
1354
1614
|
console.log("\n(--dry: nothing was submitted)");
|
|
1355
1615
|
return;
|
|
1356
1616
|
}
|
|
1357
|
-
const
|
|
1617
|
+
const credential = loadSearchCredential();
|
|
1358
1618
|
let failed = false;
|
|
1359
1619
|
if (values.check) {
|
|
1360
1620
|
if (!p.searchConsole.siteUrl) fail("--check needs seo.indexing.searchConsole.siteUrl (the Search Console property)");
|
|
1361
|
-
if (!
|
|
1621
|
+
if (!credential) fail(`--check needs a Search Console credential — ${CREDENTIAL_HINT}`);
|
|
1362
1622
|
if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
|
|
1363
|
-
const token = await
|
|
1623
|
+
const token = await googleAccessToken(credential);
|
|
1364
1624
|
console.log(`\nInspecting ${p.check.length} priority URL(s):`);
|
|
1365
1625
|
for (const url of p.check) {
|
|
1366
1626
|
const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
|
|
@@ -1384,7 +1644,7 @@ async function cmdIndexing(argv) {
|
|
|
1384
1644
|
const check = await verifyKeyFile(keyLocation, key);
|
|
1385
1645
|
if (!check.ok) {
|
|
1386
1646
|
console.error(`✖ IndexNow key file check failed — ${check.reason}`);
|
|
1387
|
-
console.error(` Write it with: vitops
|
|
1647
|
+
console.error(` Write it with: vitops search notify --write-key <public dir>`);
|
|
1388
1648
|
failed = true;
|
|
1389
1649
|
} else {
|
|
1390
1650
|
let sent = 0;
|
|
@@ -1400,9 +1660,9 @@ async function cmdIndexing(argv) {
|
|
|
1400
1660
|
if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
|
|
1401
1661
|
}
|
|
1402
1662
|
}
|
|
1403
|
-
if (p.searchConsole.enabled) if (!
|
|
1663
|
+
if (p.searchConsole.enabled) if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
|
|
1404
1664
|
else {
|
|
1405
|
-
const r = await submitSitemap(await
|
|
1665
|
+
const r = await submitSitemap(await googleAccessToken(credential), p.searchConsole.siteUrl, sitemapUrl);
|
|
1406
1666
|
if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
|
|
1407
1667
|
else {
|
|
1408
1668
|
console.error(`✖ Search Console: ${r.message}`);
|
|
@@ -1414,7 +1674,581 @@ async function cmdIndexing(argv) {
|
|
|
1414
1674
|
writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
|
|
1415
1675
|
console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
|
|
1416
1676
|
}
|
|
1417
|
-
if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops
|
|
1677
|
+
if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops search notify --check\` in a day or two to see what Google actually did.`);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Adapt a `Config` to the onboarding module's own option shape.
|
|
1681
|
+
*
|
|
1682
|
+
* The same flat field map as `toIndexingConfig`, and for the same reason — utils
|
|
1683
|
+
* cannot import the generator. `--domain` scopes to a single `site.searchConsole`
|
|
1684
|
+
* entry, and a name that isn't there is an error, not an empty run.
|
|
1685
|
+
*/
|
|
1686
|
+
function toSearchConsoleSetup(cfg, domainFilter) {
|
|
1687
|
+
const entries = cfg.site.searchConsole ?? {};
|
|
1688
|
+
const setups = Object.entries(entries).map(([domain, e]) => ({
|
|
1689
|
+
domain,
|
|
1690
|
+
...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
|
|
1691
|
+
...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
|
|
1692
|
+
}));
|
|
1693
|
+
if (domainFilter) {
|
|
1694
|
+
const one = setups.find((s) => s.domain === domainFilter);
|
|
1695
|
+
if (!one) fail(`--domain "${domainFilter}" is not in site.searchConsole (have: ${setups.map((s) => s.domain).join(", ") || "none"})`);
|
|
1696
|
+
return [one];
|
|
1697
|
+
}
|
|
1698
|
+
return setups;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* The Cloudflare DNS credential. From the environment only — a `site.json` is
|
|
1702
|
+
* committed, and this is a real secret. A `Zone:DNS:Edit` token (the standard
|
|
1703
|
+
* "Edit zone DNS" template, which also carries `Zone:Read`).
|
|
1704
|
+
*/
|
|
1705
|
+
function loadCloudflareToken() {
|
|
1706
|
+
return process.env.CLOUDFLARE_API_TOKEN || void 0;
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* The Google user-OAuth credential (refresh-token flow), from the environment.
|
|
1710
|
+
*
|
|
1711
|
+
* A user token, not a service account: onboarding acts *as a person* who can own
|
|
1712
|
+
* the sites. All-or-nothing — a partial set is a misconfiguration worth naming
|
|
1713
|
+
* rather than a reason to run degraded.
|
|
1714
|
+
*/
|
|
1715
|
+
function loadGoogleOAuth() {
|
|
1716
|
+
const clientId = process.env.VITOPS_GOOGLE_CLIENT_ID;
|
|
1717
|
+
const clientSecret = process.env.VITOPS_GOOGLE_CLIENT_SECRET;
|
|
1718
|
+
const refreshToken = process.env.VITOPS_GOOGLE_REFRESH_TOKEN;
|
|
1719
|
+
if (!clientId && !clientSecret && !refreshToken) return void 0;
|
|
1720
|
+
const missing = [
|
|
1721
|
+
["VITOPS_GOOGLE_CLIENT_ID", clientId],
|
|
1722
|
+
["VITOPS_GOOGLE_CLIENT_SECRET", clientSecret],
|
|
1723
|
+
["VITOPS_GOOGLE_REFRESH_TOKEN", refreshToken]
|
|
1724
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
1725
|
+
if (missing.length) fail(`incomplete Google OAuth credential — missing ${missing.join(", ")}`);
|
|
1726
|
+
return {
|
|
1727
|
+
clientId,
|
|
1728
|
+
clientSecret,
|
|
1729
|
+
refreshToken
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* The Google identity for `search notify`, preferring a service account.
|
|
1734
|
+
*
|
|
1735
|
+
* Search Console accepts either, and the preference is about *where this command
|
|
1736
|
+
* runs*: it fires on every deploy, in CI, and a service account has no expiry,
|
|
1737
|
+
* while a refresh token can be revoked — and for an OAuth client still in
|
|
1738
|
+
* *Testing* publishing status Google expires it after 7 days, which is a bad
|
|
1739
|
+
* property for a deploy step. So the robot wins when it is available.
|
|
1740
|
+
*
|
|
1741
|
+
* The fallback is the point, though. `search setup` needs user OAuth (verifying
|
|
1742
|
+
* a site makes the caller an owner, which should be a person), so a consumer who
|
|
1743
|
+
* ran setup already has a Google credential. Demanding a second, unrelated one
|
|
1744
|
+
* for the other half of the same command bought nothing.
|
|
1745
|
+
*/
|
|
1746
|
+
function loadSearchCredential() {
|
|
1747
|
+
const account = loadServiceAccount();
|
|
1748
|
+
if (account) return {
|
|
1749
|
+
kind: "service-account",
|
|
1750
|
+
account,
|
|
1751
|
+
scope: SCOPES.webmasters
|
|
1752
|
+
};
|
|
1753
|
+
const oauth = loadGoogleOAuth();
|
|
1754
|
+
return oauth ? {
|
|
1755
|
+
kind: "oauth",
|
|
1756
|
+
oauth
|
|
1757
|
+
} : void 0;
|
|
1758
|
+
}
|
|
1759
|
+
/** Both spellings, for an error that has to tell you what to actually set. */
|
|
1760
|
+
const CREDENTIAL_HINT = "a service account in VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS, or a user OAuth credential in VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN";
|
|
1761
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1762
|
+
/**
|
|
1763
|
+
* `vitops search` — everything that talks to search engines.
|
|
1764
|
+
*
|
|
1765
|
+
* Two subcommands, split by what they touch: `setup` onboards a domain into
|
|
1766
|
+
* Search Console (DNS + verification + property), `notify` tells the engines a
|
|
1767
|
+
* deploy happened (sitemap + IndexNow). `notify` is the former top-level
|
|
1768
|
+
* `vitops indexing`.
|
|
1769
|
+
*/
|
|
1770
|
+
async function cmdSearch(argv) {
|
|
1771
|
+
const [sub, ...rest] = argv;
|
|
1772
|
+
switch (sub) {
|
|
1773
|
+
case "setup": return cmdSearchSetup(rest);
|
|
1774
|
+
case "notify": return cmdSearchNotify(rest);
|
|
1775
|
+
case void 0:
|
|
1776
|
+
case "-h":
|
|
1777
|
+
case "--help":
|
|
1778
|
+
console.log(SEARCH_HELP);
|
|
1779
|
+
return;
|
|
1780
|
+
default: fail(`unknown "search" subcommand "${sub}" (expected: setup | notify). Try: vitops --help`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Onboard domains into Google Search Console as domain properties.
|
|
1785
|
+
*
|
|
1786
|
+
* Idempotent by construction: the planner decides each step from the domain's live
|
|
1787
|
+
* state, so a re-run of a fully-onboarded domain is all skips. `--check` reports
|
|
1788
|
+
* drift and exits non-zero without mutating; `--dry` prints the plan and stops.
|
|
1789
|
+
* DNS is only ever *created* — the verification TXT — never edited or removed.
|
|
1790
|
+
*
|
|
1791
|
+
* Search Console has no user/permission API, so granting a Google Group Full-User
|
|
1792
|
+
* access stays manual and is surfaced as a reminder, not attempted.
|
|
1793
|
+
*/
|
|
1794
|
+
async function cmdSearchSetup(argv) {
|
|
1795
|
+
const { values } = parseArgs({
|
|
1796
|
+
args: argv,
|
|
1797
|
+
options: {
|
|
1798
|
+
input: {
|
|
1799
|
+
type: "string",
|
|
1800
|
+
short: "i",
|
|
1801
|
+
default: "site.json"
|
|
1802
|
+
},
|
|
1803
|
+
"site-env": {
|
|
1804
|
+
type: "string",
|
|
1805
|
+
default: "production"
|
|
1806
|
+
},
|
|
1807
|
+
domain: { type: "string" },
|
|
1808
|
+
check: {
|
|
1809
|
+
type: "boolean",
|
|
1810
|
+
default: false
|
|
1811
|
+
},
|
|
1812
|
+
dry: {
|
|
1813
|
+
type: "boolean",
|
|
1814
|
+
default: false
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1817
|
+
allowPositionals: false
|
|
1818
|
+
});
|
|
1819
|
+
const domains = toSearchConsoleSetup(await loadConfig(resolve(values.input), values["site-env"]), values.domain);
|
|
1820
|
+
if (domains.length === 0) fail("no domains in site.searchConsole — add domains to onboard (keyed by bare hostname)");
|
|
1821
|
+
const cfToken = loadCloudflareToken();
|
|
1822
|
+
const oauth = loadGoogleOAuth();
|
|
1823
|
+
if (!cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
|
|
1824
|
+
if (!oauth) fail("set VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN — a user OAuth token scoped to siteverification + webmasters");
|
|
1825
|
+
const token = await refreshTokenGrant(oauth);
|
|
1826
|
+
const states = /* @__PURE__ */ new Map();
|
|
1827
|
+
const verifyTokens = /* @__PURE__ */ new Map();
|
|
1828
|
+
const webIds = /* @__PURE__ */ new Map();
|
|
1829
|
+
for (const d of domains) {
|
|
1830
|
+
const zone = await findZoneId(cfToken, d.domain);
|
|
1831
|
+
if (!zone.ok || !zone.zoneId) fail(`${d.domain}: ${zone.message}`);
|
|
1832
|
+
const vt = await getVerificationToken(token, d.domain);
|
|
1833
|
+
if (!vt.ok || !vt.token) fail(`${d.domain}: could not obtain a verification token — ${vt.message}`);
|
|
1834
|
+
verifyTokens.set(d.domain, vt.token);
|
|
1835
|
+
const txt = await listApexTxt(cfToken, zone.zoneId, d.domain);
|
|
1836
|
+
if (!txt.ok) fail(`${d.domain}: ${txt.message}`);
|
|
1837
|
+
const wr = await getWebResource(token, d.domain);
|
|
1838
|
+
if (!wr.ok) fail(`${d.domain}: ${wr.message}`);
|
|
1839
|
+
if (wr.id) webIds.set(d.domain, wr.id);
|
|
1840
|
+
const site = await getSite(token, siteUrlFor(d.domain));
|
|
1841
|
+
if (!site.ok) fail(`${d.domain}: ${site.message}`);
|
|
1842
|
+
states.set(d.domain, {
|
|
1843
|
+
zoneId: zone.zoneId,
|
|
1844
|
+
txtPresent: txt.contents.includes(vt.token),
|
|
1845
|
+
verified: wr.exists,
|
|
1846
|
+
currentOwners: wr.owners,
|
|
1847
|
+
propertyExists: site.exists
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
const p = plan$1({ domains }, states);
|
|
1851
|
+
console.log(formatPlan$1(p));
|
|
1852
|
+
if (values.check) {
|
|
1853
|
+
if (hasDrift(p)) {
|
|
1854
|
+
console.error("\n✖ drift — one or more domains are not fully onboarded");
|
|
1855
|
+
process.exit(1);
|
|
1856
|
+
}
|
|
1857
|
+
console.log("\n✓ every domain is onboarded");
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (values.dry) {
|
|
1861
|
+
console.log("\n(--dry: nothing was changed)");
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
const results = [];
|
|
1865
|
+
let failed = false;
|
|
1866
|
+
const delays = backoffSchedule();
|
|
1867
|
+
for (const d of domains) {
|
|
1868
|
+
const dp = p.domains.find((x) => x.domain === d.domain);
|
|
1869
|
+
const state = states.get(d.domain);
|
|
1870
|
+
const result = {
|
|
1871
|
+
domain: d.domain,
|
|
1872
|
+
txt: "—",
|
|
1873
|
+
verified: "—",
|
|
1874
|
+
property: "—",
|
|
1875
|
+
reminders: dp.reminders
|
|
1876
|
+
};
|
|
1877
|
+
if (dp.txt.action === "skip") result.txt = "present";
|
|
1878
|
+
else {
|
|
1879
|
+
const r = await createApexTxt(cfToken, state.zoneId, d.domain, verifyTokens.get(d.domain));
|
|
1880
|
+
if (!r.ok) {
|
|
1881
|
+
console.error(`✖ ${d.domain} TXT: ${r.message}`);
|
|
1882
|
+
result.txt = "failed";
|
|
1883
|
+
failed = true;
|
|
1884
|
+
results.push(result);
|
|
1885
|
+
continue;
|
|
1886
|
+
}
|
|
1887
|
+
result.txt = "created";
|
|
1888
|
+
}
|
|
1889
|
+
let verified = state.verified;
|
|
1890
|
+
if (verified) result.verified = "yes";
|
|
1891
|
+
else {
|
|
1892
|
+
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
1893
|
+
const v = await verifyWebResource(token, d.domain);
|
|
1894
|
+
if (v.ok) {
|
|
1895
|
+
verified = true;
|
|
1896
|
+
if (v.id) webIds.set(d.domain, v.id);
|
|
1897
|
+
state.currentOwners = v.owners;
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1900
|
+
if (attempt < delays.length - 1) {
|
|
1901
|
+
const wait = delays[attempt];
|
|
1902
|
+
console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
|
|
1903
|
+
await sleep(wait);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
result.verified = verified ? "yes" : "pending";
|
|
1907
|
+
}
|
|
1908
|
+
if (!verified) {
|
|
1909
|
+
results.push(result);
|
|
1910
|
+
continue;
|
|
1911
|
+
}
|
|
1912
|
+
if (dp.property.action === "skip") result.property = "present";
|
|
1913
|
+
else {
|
|
1914
|
+
const r = await addSite(token, dp.siteUrl);
|
|
1915
|
+
if (!r.ok) {
|
|
1916
|
+
console.error(`✖ ${d.domain} property: ${r.message}`);
|
|
1917
|
+
result.property = "failed";
|
|
1918
|
+
failed = true;
|
|
1919
|
+
} else result.property = "added";
|
|
1920
|
+
}
|
|
1921
|
+
if (dp.owners.action === "update" && dp.ownersToAdd.length) {
|
|
1922
|
+
const id = webIds.get(d.domain);
|
|
1923
|
+
if (!id) {
|
|
1924
|
+
console.error(`✖ ${d.domain} owners: no web-resource id (verify may be too fresh)`);
|
|
1925
|
+
failed = true;
|
|
1926
|
+
} else {
|
|
1927
|
+
const r = await updateOwners(token, id, d.domain, ownerUnion(state.currentOwners, dp.ownersToAdd));
|
|
1928
|
+
if (!r.ok) {
|
|
1929
|
+
console.error(`✖ ${d.domain} owners: ${r.message}`);
|
|
1930
|
+
failed = true;
|
|
1931
|
+
} else console.log(`✓ ${d.domain}: added ${dp.ownersToAdd.length} owner(s)`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
results.push(result);
|
|
1935
|
+
}
|
|
1936
|
+
console.log(`\n${formatSummary(results)}`);
|
|
1937
|
+
if (failed) process.exit(1);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Adapt a `Config` to the ads module's own option shape.
|
|
1941
|
+
*
|
|
1942
|
+
* The same flat field map as `toSearchConsoleSetup` and `toIndexingConfig`, for the
|
|
1943
|
+
* same reason — utils cannot import the generator. The domain default is the one
|
|
1944
|
+
* derived value: a platform verifies the host the site is published at, so
|
|
1945
|
+
* `domains.canonical` answers it, and only an ad account verified against some
|
|
1946
|
+
* other host needs `site.ads.<provider>.domain` stated.
|
|
1947
|
+
*/
|
|
1948
|
+
function toAdProperties(cfg, providerFilter) {
|
|
1949
|
+
const canonicalHost = (() => {
|
|
1950
|
+
const url = cfg.site.domains?.canonical;
|
|
1951
|
+
if (!url) return void 0;
|
|
1952
|
+
try {
|
|
1953
|
+
return new URL(url).hostname;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
})();
|
|
1958
|
+
const entries = cfg.site.ads ?? {};
|
|
1959
|
+
const properties = AD_PROVIDER_KEYS.filter((p) => entries[p] != null).map((provider) => {
|
|
1960
|
+
const e = entries[provider];
|
|
1961
|
+
const domain = e.domain ?? canonicalHost;
|
|
1962
|
+
return {
|
|
1963
|
+
provider,
|
|
1964
|
+
...e.accountId ? { accountId: e.accountId } : {},
|
|
1965
|
+
...e.pixelId ? { pixelId: e.pixelId } : {},
|
|
1966
|
+
...e.conversionLabel ? { conversionLabel: e.conversionLabel } : {},
|
|
1967
|
+
...domain ? { domain } : {},
|
|
1968
|
+
...e.domainVerification ? { domainVerification: e.domainVerification } : {},
|
|
1969
|
+
...e.category ? { category: e.category } : {},
|
|
1970
|
+
...e.enabled != null ? { enabled: e.enabled } : {}
|
|
1971
|
+
};
|
|
1972
|
+
});
|
|
1973
|
+
if (providerFilter) {
|
|
1974
|
+
if (!isAdProvider(providerFilter)) fail(`--provider "${providerFilter}" is not a known ad platform (expected: ${AD_PROVIDER_KEYS.join(" | ")})`);
|
|
1975
|
+
const one = properties.find((p) => p.provider === providerFilter);
|
|
1976
|
+
if (!one) fail(`--provider "${providerFilter}" is not in site.ads (have: ${properties.map((p) => p.provider).join(", ") || "none"})`);
|
|
1977
|
+
return [one];
|
|
1978
|
+
}
|
|
1979
|
+
return properties;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* `vitops ads` — everything that links this site to an ad platform.
|
|
1983
|
+
*
|
|
1984
|
+
* Three subcommands, split by what they touch: `setup` writes DNS, `tags` writes
|
|
1985
|
+
* nothing and prints markup, `lint` reads and reports.
|
|
1986
|
+
*/
|
|
1987
|
+
async function cmdAds(argv) {
|
|
1988
|
+
const [sub, ...rest] = argv;
|
|
1989
|
+
switch (sub) {
|
|
1990
|
+
case "setup": return cmdAdsSetup(rest);
|
|
1991
|
+
case "tags": return cmdAdsTags(rest);
|
|
1992
|
+
case "lint": return cmdAdsLint(rest);
|
|
1993
|
+
case void 0:
|
|
1994
|
+
case "-h":
|
|
1995
|
+
case "--help":
|
|
1996
|
+
console.log(ADS_HELP);
|
|
1997
|
+
return;
|
|
1998
|
+
default: fail(`unknown "ads" subcommand "${sub}" (expected: setup | tags | lint). Try: vitops --help`);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Link each configured ad property: ensure the platform's domain-verification TXT,
|
|
2003
|
+
* report what only a human can finish.
|
|
2004
|
+
*
|
|
2005
|
+
* Idempotent by construction — the planner decides each step from live DNS, so a
|
|
2006
|
+
* re-run of a linked property is all skips. `--check` reports drift and exits
|
|
2007
|
+
* non-zero without mutating; `--dry` prints the plan and stops. Neither is offline:
|
|
2008
|
+
* the DNS observation runs before the planner either way.
|
|
2009
|
+
*
|
|
2010
|
+
* The one thing this does that `search setup` does not is **ask**. A first run has
|
|
2011
|
+
* no verification token, because the token doesn't exist until someone fetches it
|
|
2012
|
+
* from the platform UI — so a blocked step becomes a prompt, the answer is folded
|
|
2013
|
+
* into the setup, and the planner runs again on it. That re-plan matters: what gets
|
|
2014
|
+
* executed is always a plan the pure planner produced, never a special case built
|
|
2015
|
+
* out of an answer.
|
|
2016
|
+
*/
|
|
2017
|
+
async function cmdAdsSetup(argv) {
|
|
2018
|
+
const { values } = parseArgs({
|
|
2019
|
+
args: argv,
|
|
2020
|
+
options: {
|
|
2021
|
+
input: {
|
|
2022
|
+
type: "string",
|
|
2023
|
+
short: "i",
|
|
2024
|
+
default: "site.json"
|
|
2025
|
+
},
|
|
2026
|
+
"site-env": {
|
|
2027
|
+
type: "string",
|
|
2028
|
+
default: "production"
|
|
2029
|
+
},
|
|
2030
|
+
provider: { type: "string" },
|
|
2031
|
+
check: {
|
|
2032
|
+
type: "boolean",
|
|
2033
|
+
default: false
|
|
2034
|
+
},
|
|
2035
|
+
dry: {
|
|
2036
|
+
type: "boolean",
|
|
2037
|
+
default: false
|
|
2038
|
+
},
|
|
2039
|
+
"no-prompt": {
|
|
2040
|
+
type: "boolean",
|
|
2041
|
+
default: false
|
|
2042
|
+
},
|
|
2043
|
+
"no-write": {
|
|
2044
|
+
type: "boolean",
|
|
2045
|
+
default: false
|
|
2046
|
+
}
|
|
2047
|
+
},
|
|
2048
|
+
allowPositionals: false
|
|
2049
|
+
});
|
|
2050
|
+
const input = resolve(values.input);
|
|
2051
|
+
let properties = toAdProperties(await loadConfig(input, values["site-env"]), values.provider);
|
|
2052
|
+
if (properties.length === 0) fail("no properties in site.ads — add the ad platforms this site is linked to");
|
|
2053
|
+
const dry = values.dry;
|
|
2054
|
+
const check = values.check;
|
|
2055
|
+
const needsDns = properties.some((p) => AD_PLATFORMS[p.provider].verification.method === "dns-txt");
|
|
2056
|
+
const cfToken = loadCloudflareToken();
|
|
2057
|
+
if (needsDns && !cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
|
|
2058
|
+
/** Observe live DNS per domain. Cached by domain: several platforms share one. */
|
|
2059
|
+
const observe = async () => {
|
|
2060
|
+
const states = /* @__PURE__ */ new Map();
|
|
2061
|
+
if (!cfToken) return states;
|
|
2062
|
+
for (const p of properties) {
|
|
2063
|
+
const domain = p.domain;
|
|
2064
|
+
if (!domain || states.has(domain)) continue;
|
|
2065
|
+
if (AD_PLATFORMS[p.provider].verification.method !== "dns-txt") continue;
|
|
2066
|
+
const zone = await findZoneId(cfToken, domain);
|
|
2067
|
+
if (!zone.ok || !zone.zoneId) fail(`${domain}: ${zone.message}`);
|
|
2068
|
+
const txt = await listApexTxt(cfToken, zone.zoneId, domain);
|
|
2069
|
+
if (!txt.ok) fail(`${domain}: ${txt.message}`);
|
|
2070
|
+
states.set(domain, {
|
|
2071
|
+
zoneId: zone.zoneId,
|
|
2072
|
+
txtContents: txt.contents
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
return states;
|
|
2076
|
+
};
|
|
2077
|
+
let states = await observe();
|
|
2078
|
+
let adsPlan = plan$2({ properties }, states);
|
|
2079
|
+
const interactive = canPrompt({
|
|
2080
|
+
dry,
|
|
2081
|
+
check,
|
|
2082
|
+
noPrompt: values["no-prompt"]
|
|
2083
|
+
});
|
|
2084
|
+
const gaps = missingFields(adsPlan);
|
|
2085
|
+
if (gaps.length && interactive) {
|
|
2086
|
+
const answers = [];
|
|
2087
|
+
for (const gap of gaps) {
|
|
2088
|
+
const value = await ask(questionFor(gap.provider, gap.needs));
|
|
2089
|
+
if (value) answers.push({
|
|
2090
|
+
provider: gap.provider,
|
|
2091
|
+
needs: gap.needs,
|
|
2092
|
+
value
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
if (answers.length) {
|
|
2096
|
+
properties = properties.map((p) => {
|
|
2097
|
+
return answers.filter((a) => a.provider === p.provider).reduce((acc, a) => ({
|
|
2098
|
+
...acc,
|
|
2099
|
+
[a.needs]: a.value
|
|
2100
|
+
}), p);
|
|
2101
|
+
});
|
|
2102
|
+
adsPlan = plan$2({ properties }, states);
|
|
2103
|
+
if (!values["no-write"]) for (const a of answers) {
|
|
2104
|
+
const res = writeConfigPatch(input, a.provider, a.needs, a.value);
|
|
2105
|
+
if (res.written) console.log(`✓ wrote site.ads.${a.provider}.${a.needs} to ${input}`);
|
|
2106
|
+
else console.error(`! ${res.reason}`);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
states = await observe();
|
|
2110
|
+
adsPlan = plan$2({ properties }, states);
|
|
2111
|
+
} else if (gaps.length && !dry && !check) for (const gap of gaps) console.error(`! ${missingFieldMessage(gap.provider, gap.needs)}`);
|
|
2112
|
+
if (dry || check) {
|
|
2113
|
+
console.log(formatPlan$2(adsPlan));
|
|
2114
|
+
if (check && hasDrift$1(adsPlan)) {
|
|
2115
|
+
console.error("\n✖ ad properties are not fully linked");
|
|
2116
|
+
process.exit(1);
|
|
2117
|
+
}
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const results = [];
|
|
2121
|
+
let failed = false;
|
|
2122
|
+
for (const p of adsPlan.properties) {
|
|
2123
|
+
const result = {
|
|
2124
|
+
provider: p.provider,
|
|
2125
|
+
domain: p.domain ?? "—",
|
|
2126
|
+
txt: "n/a",
|
|
2127
|
+
tag: p.tag.action === "skip" ? "ready" : "blocked",
|
|
2128
|
+
reminders: p.reminders
|
|
2129
|
+
};
|
|
2130
|
+
if (p.txt.action === "blocked" || p.tag.action === "blocked") failed = true;
|
|
2131
|
+
if (p.txt.action === "blocked") result.txt = "blocked";
|
|
2132
|
+
else if (p.txt.action === "skip") result.txt = AD_PLATFORMS[p.provider].verification.method === "dns-txt" ? "present" : "n/a";
|
|
2133
|
+
else {
|
|
2134
|
+
const zoneId = p.domain ? states.get(p.domain)?.zoneId : void 0;
|
|
2135
|
+
if (!cfToken || !zoneId || !p.domain || !p.txtContent) {
|
|
2136
|
+
console.error(`✖ ${p.provider}: no Cloudflare zone for ${p.domain ?? "(no domain)"}`);
|
|
2137
|
+
result.txt = "failed";
|
|
2138
|
+
failed = true;
|
|
2139
|
+
} else {
|
|
2140
|
+
const r = await createApexTxt(cfToken, zoneId, p.domain, p.txtContent);
|
|
2141
|
+
if (!r.ok) {
|
|
2142
|
+
console.error(`✖ ${p.provider} TXT: ${r.message}`);
|
|
2143
|
+
result.txt = "failed";
|
|
2144
|
+
failed = true;
|
|
2145
|
+
} else {
|
|
2146
|
+
console.log(`✓ ${p.provider}: created TXT on ${p.domain}`);
|
|
2147
|
+
result.txt = "created";
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
results.push(result);
|
|
2152
|
+
}
|
|
2153
|
+
console.log(`\n${formatSummary$1(results)}`);
|
|
2154
|
+
if (results.some((r) => r.txt === "created")) console.log("\n DNS takes minutes to propagate before a platform can see the record.");
|
|
2155
|
+
if (results.some((r) => r.reminders.length)) console.log(" Finish the reminders above in the platform UI — no API covers those.");
|
|
2156
|
+
if (failed) process.exit(1);
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Print each configured pixel as an inert, consent-gated `<script>`.
|
|
2160
|
+
*
|
|
2161
|
+
* The universal delivery path, the same reasoning as `vitops legal`: every consumer
|
|
2162
|
+
* has this CLI whatever their stack, so Bricks, WordPress and a hand-built site all
|
|
2163
|
+
* get the correct gated markup without integration code.
|
|
2164
|
+
*/
|
|
2165
|
+
async function cmdAdsTags(argv) {
|
|
2166
|
+
const { values } = parseArgs({
|
|
2167
|
+
args: argv,
|
|
2168
|
+
options: {
|
|
2169
|
+
input: {
|
|
2170
|
+
type: "string",
|
|
2171
|
+
short: "i",
|
|
2172
|
+
default: "site.json"
|
|
2173
|
+
},
|
|
2174
|
+
"site-env": {
|
|
2175
|
+
type: "string",
|
|
2176
|
+
default: "production"
|
|
2177
|
+
},
|
|
2178
|
+
provider: { type: "string" },
|
|
2179
|
+
strategy: {
|
|
2180
|
+
type: "string",
|
|
2181
|
+
default: "idle"
|
|
2182
|
+
}
|
|
2183
|
+
},
|
|
2184
|
+
allowPositionals: false
|
|
2185
|
+
});
|
|
2186
|
+
const strategy = values.strategy;
|
|
2187
|
+
if (![
|
|
2188
|
+
"idle",
|
|
2189
|
+
"async",
|
|
2190
|
+
"interaction"
|
|
2191
|
+
].includes(strategy)) fail(`--strategy "${strategy}" is not one of: idle | async | interaction`);
|
|
2192
|
+
const properties = toAdProperties(await loadConfig(resolve(values.input), values["site-env"]), values.provider);
|
|
2193
|
+
if (properties.length === 0) fail("no properties in site.ads — nothing to emit");
|
|
2194
|
+
const out = [];
|
|
2195
|
+
for (const p of properties) {
|
|
2196
|
+
if (p.enabled === false) continue;
|
|
2197
|
+
const tag = renderTag(p, strategy);
|
|
2198
|
+
if (!tag) {
|
|
2199
|
+
console.error(`! ${p.provider}: no ${AD_PLATFORMS[p.provider].tag.needs} — skipped (run \`vitops ads setup\` to be prompted for it)`);
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
out.push(tag);
|
|
2203
|
+
}
|
|
2204
|
+
if (out.length === 0) fail("no ad property has a tag id yet");
|
|
2205
|
+
console.log(out.join("\n\n"));
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Report the ad-property gaps that are invisible at runtime.
|
|
2209
|
+
*
|
|
2210
|
+
* Each finding is a silent failure, which is the bar for being here at all: an
|
|
2211
|
+
* uncaptured click ID looks exactly like organic traffic, and a pixel on a site
|
|
2212
|
+
* with tracking off produces conversions no campaign gets credit for.
|
|
2213
|
+
*/
|
|
2214
|
+
async function cmdAdsLint(argv) {
|
|
2215
|
+
const { values } = parseArgs({
|
|
2216
|
+
args: argv,
|
|
2217
|
+
options: {
|
|
2218
|
+
input: {
|
|
2219
|
+
type: "string",
|
|
2220
|
+
short: "i",
|
|
2221
|
+
default: "site.json"
|
|
2222
|
+
},
|
|
2223
|
+
"site-env": {
|
|
2224
|
+
type: "string",
|
|
2225
|
+
default: "production"
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
allowPositionals: false
|
|
2229
|
+
});
|
|
2230
|
+
const cfg = await loadConfig(resolve(values.input), values["site-env"]);
|
|
2231
|
+
const properties = toAdProperties(cfg, void 0);
|
|
2232
|
+
if (properties.length === 0) {
|
|
2233
|
+
console.log("· no site.ads properties — nothing to check");
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
const findings = [];
|
|
2237
|
+
const trackingOn = cfg.site.tracking?.enabled === true;
|
|
2238
|
+
for (const p of properties) {
|
|
2239
|
+
const platform = AD_PLATFORMS[p.provider];
|
|
2240
|
+
if (p.enabled === false) continue;
|
|
2241
|
+
if (!tagId(p)) findings.push(`${p.provider}: no ${platform.tag.needs} — the property is on record but no tag can be emitted`);
|
|
2242
|
+
const uncaptured = platform.clickIdParams.filter((param) => !(param in PLATFORM_PARAMS));
|
|
2243
|
+
if (uncaptured.length) findings.push(`${p.provider}: click ID ${uncaptured.join(", ")} is not in the capture vocabulary — every conversion from this platform will arrive unattributed`);
|
|
2244
|
+
if (!trackingOn) findings.push(`${p.provider}: site.tracking.enabled is not true, so ${platform.clickIdParams.join("/")} is never captured — conversions will be unattributed`);
|
|
2245
|
+
}
|
|
2246
|
+
if (findings.length === 0) {
|
|
2247
|
+
console.log(`✓ ${properties.length} ad propert${properties.length === 1 ? "y" : "ies"} check out`);
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
for (const f of findings) console.error(`✖ ${f}`);
|
|
2251
|
+
process.exit(1);
|
|
1418
2252
|
}
|
|
1419
2253
|
function cliVersion() {
|
|
1420
2254
|
try {
|
|
@@ -1641,9 +2475,10 @@ async function main() {
|
|
|
1641
2475
|
case "lint": return cmdLint(rest);
|
|
1642
2476
|
case "legal": return cmdLegal(rest);
|
|
1643
2477
|
case "icons": return cmdIcons(rest);
|
|
1644
|
-
case "
|
|
2478
|
+
case "search": return cmdSearch(rest);
|
|
2479
|
+
case "ads": return cmdAds(rest);
|
|
1645
2480
|
case "media": return cmdMedia(rest);
|
|
1646
|
-
default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons |
|
|
2481
|
+
default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | ads | media). Try: vitops --help`);
|
|
1647
2482
|
}
|
|
1648
2483
|
}
|
|
1649
2484
|
main().catch((err) => fail(err.message));
|