@getvitops/cli 4.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 +150 -0
- package/dist/cli.mjs +526 -4
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +6 -1
package/dist/cli.mjs
CHANGED
|
@@ -4,11 +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
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
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 {
|
|
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";
|
|
12
15
|
|
|
13
16
|
//#region src/agents.ts
|
|
14
17
|
/**
|
|
@@ -126,6 +129,144 @@ function linkSkill(linkPath, targetDir) {
|
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
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
|
+
|
|
129
270
|
//#endregion
|
|
130
271
|
//#region src/css-scan.ts
|
|
131
272
|
/** Strip comments, preserving newlines so line numbers survive. */
|
|
@@ -558,6 +699,7 @@ function lintSource(files, v, format) {
|
|
|
558
699
|
* vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
|
|
559
700
|
* vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
|
|
560
701
|
* vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
|
|
702
|
+
* vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
|
|
561
703
|
* vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
|
|
562
704
|
*
|
|
563
705
|
* Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
|
|
@@ -602,6 +744,7 @@ Usage:
|
|
|
602
744
|
vitops legal [options] Render legal documents from a site config
|
|
603
745
|
vitops icons [options] Report which icons your source uses, and build the sprite
|
|
604
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
|
|
605
748
|
vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
|
|
606
749
|
|
|
607
750
|
Generate options:
|
|
@@ -725,6 +868,53 @@ Search notify options:
|
|
|
725
868
|
_REFRESH_TOKEN). The service account is preferred when both are set — this runs
|
|
726
869
|
on every deploy, and it does not expire.
|
|
727
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.
|
|
917
|
+
|
|
728
918
|
Media options:
|
|
729
919
|
--raw <dir> Directory of unprocessed video, walked recursively
|
|
730
920
|
(default: ./raw)
|
|
@@ -758,6 +948,14 @@ const SEARCH_HELP = `vitops search — Google Search Console
|
|
|
758
948
|
vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
|
|
759
949
|
vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
|
|
760
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
|
+
|
|
761
959
|
Run \`vitops --help\` for the full option list for each subcommand.
|
|
762
960
|
`;
|
|
763
961
|
function fail(msg) {
|
|
@@ -1012,7 +1210,7 @@ async function cmdMedia(argv) {
|
|
|
1012
1210
|
onProgress: (message) => console.error(` … ${message}`)
|
|
1013
1211
|
});
|
|
1014
1212
|
if (values.dry) {
|
|
1015
|
-
console.log(formatPlan$
|
|
1213
|
+
console.log(formatPlan$3(result.plan));
|
|
1016
1214
|
return;
|
|
1017
1215
|
}
|
|
1018
1216
|
console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
|
|
@@ -1255,6 +1453,15 @@ async function cmdLegal(argv) {
|
|
|
1255
1453
|
docs,
|
|
1256
1454
|
output
|
|
1257
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.`);
|
|
1258
1465
|
if (!values.out) {
|
|
1259
1466
|
process.stdout.write(Object.values(files).join("\n"));
|
|
1260
1467
|
return;
|
|
@@ -1729,6 +1936,320 @@ async function cmdSearchSetup(argv) {
|
|
|
1729
1936
|
console.log(`\n${formatSummary(results)}`);
|
|
1730
1937
|
if (failed) process.exit(1);
|
|
1731
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);
|
|
2252
|
+
}
|
|
1732
2253
|
function cliVersion() {
|
|
1733
2254
|
try {
|
|
1734
2255
|
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
@@ -1955,8 +2476,9 @@ async function main() {
|
|
|
1955
2476
|
case "legal": return cmdLegal(rest);
|
|
1956
2477
|
case "icons": return cmdIcons(rest);
|
|
1957
2478
|
case "search": return cmdSearch(rest);
|
|
2479
|
+
case "ads": return cmdAds(rest);
|
|
1958
2480
|
case "media": return cmdMedia(rest);
|
|
1959
|
-
default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | media). Try: vitops --help`);
|
|
2481
|
+
default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | ads | media). Try: vitops --help`);
|
|
1960
2482
|
}
|
|
1961
2483
|
}
|
|
1962
2484
|
main().catch((err) => fail(err.message));
|