@getvitops/cli 8.1.0 → 8.2.0-alpha.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 +216 -14
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +657 -29
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -6
package/dist/cli.mjs
CHANGED
|
@@ -14,8 +14,11 @@ import { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan as form
|
|
|
14
14
|
import { AD_PLATFORMS, AD_PROVIDER_KEYS, formatPlan as formatPlan$4, formatSummary as formatSummary$1, hasDrift as hasDrift$1, isAdProvider, missingFields, plan as plan$3, renderTag, tagId } from "@getvitops/utils/ads";
|
|
15
15
|
import { PLATFORM_PARAMS } from "@getvitops/utils/tracking";
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
|
+
import { zohoAccountsHost, zohoApiHost } from "@getvitops/utils/crm";
|
|
18
|
+
import { backoffSchedule as backoffSchedule$1, blockedDomains, collectFromAddresses, dnsStatus, enableSending, findZoneForHost, formatPlan as formatPlan$5, formatSummary as formatSummary$2, hasDrift as hasDrift$2, listSendingSubdomains, planSendingDomains, resolveTxt, sendingDns } from "@getvitops/utils/email";
|
|
19
|
+
import { normalizeEmailChannel, resolveSender } from "@getvitops/utils/notify";
|
|
17
20
|
import { execFileSync } from "node:child_process";
|
|
18
|
-
import { adminCollaboratorIds, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan as formatPlan$
|
|
21
|
+
import { adminCollaboratorIds, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan as formatPlan$6, formatSummary as formatSummary$3, ghAuthed, mainProtected, originRemote, plan as plan$4, planDomainConsistency, protectMain, pushBranches, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami } from "@getvitops/utils/hosting";
|
|
19
22
|
|
|
20
23
|
//#region src/args.ts
|
|
21
24
|
/**
|
|
@@ -296,6 +299,35 @@ function loadSearchCredential() {
|
|
|
296
299
|
}
|
|
297
300
|
/** Both spellings, for an error that has to tell you what to actually set. */
|
|
298
301
|
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";
|
|
302
|
+
/**
|
|
303
|
+
* The HubSpot private-app access token, from the environment. Never the
|
|
304
|
+
* config file — same rule as every other credential here.
|
|
305
|
+
*/
|
|
306
|
+
function loadHubSpotToken() {
|
|
307
|
+
return process.env.VITOPS_HUBSPOT_ACCESS_TOKEN || void 0;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The Zoho self-client OAuth credential, for Bigin. All-or-nothing, matching
|
|
311
|
+
* `loadGoogleOAuth`'s own rule: a partial set is a misconfiguration worth
|
|
312
|
+
* naming, not a reason to run degraded.
|
|
313
|
+
*/
|
|
314
|
+
function loadZohoOAuth() {
|
|
315
|
+
const clientId = process.env.VITOPS_ZOHO_CLIENT_ID;
|
|
316
|
+
const clientSecret = process.env.VITOPS_ZOHO_CLIENT_SECRET;
|
|
317
|
+
const refreshToken = process.env.VITOPS_ZOHO_REFRESH_TOKEN;
|
|
318
|
+
if (!clientId && !clientSecret && !refreshToken) return void 0;
|
|
319
|
+
const missing = [
|
|
320
|
+
["VITOPS_ZOHO_CLIENT_ID", clientId],
|
|
321
|
+
["VITOPS_ZOHO_CLIENT_SECRET", clientSecret],
|
|
322
|
+
["VITOPS_ZOHO_REFRESH_TOKEN", refreshToken]
|
|
323
|
+
].filter(([, v]) => !v).map(([k]) => k);
|
|
324
|
+
if (missing.length) fail(`incomplete Zoho OAuth credential — missing ${missing.join(", ")}`);
|
|
325
|
+
return {
|
|
326
|
+
clientId,
|
|
327
|
+
clientSecret,
|
|
328
|
+
refreshToken
|
|
329
|
+
};
|
|
330
|
+
}
|
|
299
331
|
|
|
300
332
|
//#endregion
|
|
301
333
|
//#region src/help.ts
|
|
@@ -329,6 +361,7 @@ Usage:
|
|
|
329
361
|
vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
|
|
330
362
|
vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
|
|
331
363
|
vitops setup <sub> [opts] Scripted onboarding: repo, hosting, domain, search, ads, all
|
|
364
|
+
vitops email <sub> [opts] Sender-domain onboarding: check (setup) + deploy guard (check)
|
|
332
365
|
vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
|
|
333
366
|
|
|
334
367
|
Generate options:
|
|
@@ -577,6 +610,33 @@ Ads lint options:
|
|
|
577
610
|
off (every conversion arrives unattributed), and a configured property with no
|
|
578
611
|
tag ID. Exits non-zero on a finding.
|
|
579
612
|
|
|
613
|
+
Crm check options:
|
|
614
|
+
-i, --input <path> Site config carrying site.crm (default: ./site.json)
|
|
615
|
+
--site-env <env> Environment to resolve (default: production)
|
|
616
|
+
|
|
617
|
+
Fetches the live field/property list (HubSpot: crm/v3/properties/contacts;
|
|
618
|
+
Bigin: bigin/v2/settings/fields) and reports every fieldMap/trackingFields
|
|
619
|
+
target that doesn't exist there. HubSpot rejects a submission naming an
|
|
620
|
+
unrecognised property; Bigin silently ignores it — this is the only way to
|
|
621
|
+
see a broken mapping before a real submission finds it. Needs
|
|
622
|
+
VITOPS_HUBSPOT_ACCESS_TOKEN, VITOPS_ZOHO_CLIENT_ID/_CLIENT_SECRET/
|
|
623
|
+
_REFRESH_TOKEN, or (unused for now) VITOPS_JOBBER_CLIENT_ID/_CLIENT_SECRET/
|
|
624
|
+
_REFRESH_TOKEN, matching whichever provider site.crm names. Jobber currently
|
|
625
|
+
renders fieldMap/trackingFields as note-text lines rather than app-configured
|
|
626
|
+
custom fields, so there is nothing live to check there yet. Exits non-zero
|
|
627
|
+
on any missing target.
|
|
628
|
+
|
|
629
|
+
Crm setup options:
|
|
630
|
+
-i, --input <path> Site config carrying site.crm (default: ./site.json)
|
|
631
|
+
--site-env <env> Environment to resolve (default: production)
|
|
632
|
+
--dry Print what would be created and exit. Creates nothing.
|
|
633
|
+
|
|
634
|
+
HubSpot: creates missing contact properties named by fieldMap/trackingFields.
|
|
635
|
+
Bigin: no such API exists, so this always prints a manual reminder listing
|
|
636
|
+
the fields to add in Zoho Bigin's field editor, then re-run "crm check".
|
|
637
|
+
Jobber: app-configured custom fields aren't wired up yet, so this prints a
|
|
638
|
+
reminder instead of creating anything.
|
|
639
|
+
|
|
580
640
|
Setup repo options:
|
|
581
641
|
--dry Print the plan and exit
|
|
582
642
|
--check Report drift/blocked steps and exit non-zero, mutating nothing
|
|
@@ -632,6 +692,46 @@ Setup all options:
|
|
|
632
692
|
Runs repo, hosting, search, ads in order. Stops at the first stage that fails;
|
|
633
693
|
a blocked step (the API token, GitHub Pro/Team gating) is reported, not fatal.
|
|
634
694
|
|
|
695
|
+
Email subcommands:
|
|
696
|
+
vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
|
|
697
|
+
vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
|
|
698
|
+
|
|
699
|
+
Email setup options:
|
|
700
|
+
-i, --input <path> Site config carrying site.notifications.email (default: ./site.json)
|
|
701
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
702
|
+
[domain] Onboard this exact name instead of deriving candidates from
|
|
703
|
+
config + a source scan
|
|
704
|
+
--src <dir> Source to scan for from: literals as a supplement to config
|
|
705
|
+
(default: ./src)
|
|
706
|
+
--dry Print the plan and exit. Needs no credential — plans from
|
|
707
|
+
scratch and says so.
|
|
708
|
+
--check Report drift and exit non-zero. Mutates nothing.
|
|
709
|
+
--no-prompt Never ask before enabling a domain (the default when
|
|
710
|
+
stdin is not a TTY, e.g. in CI — it just enables)
|
|
711
|
+
--timeout <s> Cap on each DNS-propagation poll wait, in seconds (default: 5)
|
|
712
|
+
|
|
713
|
+
The onboarded entity is an EXACT name, and it is usually a subdomain
|
|
714
|
+
(send.example.com), never the apex a from address names. When the exact
|
|
715
|
+
domain is absent but a sibling in the same zone is already onboarded, this
|
|
716
|
+
is reported blocked rather than silently enabling the apex too — enabling
|
|
717
|
+
it rewrites _dmarc.<domain>, which can tighten policy on every OTHER mail
|
|
718
|
+
sent from that domain. Sending from the onboarded sibling is the
|
|
719
|
+
recommended fix. Needs CLOUDFLARE_API_TOKEN (Zone:Read + an Email Sending
|
|
720
|
+
scope — broader than the "Edit zone DNS" template other commands use).
|
|
721
|
+
|
|
722
|
+
Email check options:
|
|
723
|
+
-i, --input <path> Site config carrying site.notifications.email (default: ./site.json)
|
|
724
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
725
|
+
--src <dir> Source to scan for from: literals (default: ./src)
|
|
726
|
+
--strict Exit non-zero on drift (the default just warns and exits 0)
|
|
727
|
+
--skip Skip the check entirely (same as VITOPS_SKIP_EMAIL_CHECK)
|
|
728
|
+
|
|
729
|
+
Meant to run before a deploy. With no CLOUDFLARE_API_TOKEN it reports "not
|
|
730
|
+
checked" and exits 0, so a CI job without the secret never fails on the
|
|
731
|
+
check itself. Skip per-site with site.notifications.email.verifySender: false
|
|
732
|
+
when the scan can't see the real from address and the warning would be a
|
|
733
|
+
false positive.
|
|
734
|
+
|
|
635
735
|
Media options:
|
|
636
736
|
--raw <dir> Directory of unprocessed video, walked recursively
|
|
637
737
|
(default: ./raw)
|
|
@@ -673,6 +773,20 @@ const ADS_HELP = `vitops ads — link this site to its ad properties
|
|
|
673
773
|
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
674
774
|
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
675
775
|
|
|
776
|
+
Run \`vitops --help\` for the full option list for each subcommand.
|
|
777
|
+
`;
|
|
778
|
+
const CRM_HELP = `vitops crm — push form submissions into a client CRM
|
|
779
|
+
|
|
780
|
+
vitops crm check [opts] Compare site.crm's fieldMap/trackingFields against the live CRM
|
|
781
|
+
vitops crm setup [opts] Create missing HubSpot properties (Bigin/Jobber have no such API)
|
|
782
|
+
|
|
783
|
+
Run \`vitops --help\` for the full option list for each subcommand.
|
|
784
|
+
`;
|
|
785
|
+
const EMAIL_HELP = `vitops email — sender-domain onboarding checks
|
|
786
|
+
|
|
787
|
+
vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
|
|
788
|
+
vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
|
|
789
|
+
|
|
676
790
|
Run \`vitops --help\` for the full option list for each subcommand.
|
|
677
791
|
`;
|
|
678
792
|
const SETUP_HELP$1 = `vitops setup — the scripted path from an empty scaffold to a live site
|
|
@@ -706,7 +820,9 @@ const COMMANDS = [
|
|
|
706
820
|
"icons",
|
|
707
821
|
"search",
|
|
708
822
|
"ads",
|
|
823
|
+
"crm",
|
|
709
824
|
"setup",
|
|
825
|
+
"email",
|
|
710
826
|
"media"
|
|
711
827
|
];
|
|
712
828
|
/**
|
|
@@ -736,7 +852,7 @@ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
|
|
|
736
852
|
/** The help text to print for `vitops <command> [sub] --help`. */
|
|
737
853
|
function helpFor(command, argv) {
|
|
738
854
|
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
|
|
739
|
-
return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? SEARCH_HELP : command === "ads" ? ADS_HELP : command === "setup" ? SETUP_HELP$1 : void 0) ?? HELP;
|
|
855
|
+
return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? SEARCH_HELP : command === "ads" ? ADS_HELP : command === "crm" ? CRM_HELP : command === "setup" ? SETUP_HELP$1 : command === "email" ? EMAIL_HELP : void 0) ?? HELP;
|
|
740
856
|
}
|
|
741
857
|
|
|
742
858
|
//#endregion
|
|
@@ -778,11 +894,12 @@ function toIndexingConfig(cfg, siteEnv) {
|
|
|
778
894
|
*/
|
|
779
895
|
function toSearchConsoleSetup(cfg, domainFilter) {
|
|
780
896
|
const entries = cfg.site.searchConsole ?? {};
|
|
781
|
-
|
|
897
|
+
const setups = Object.entries(entries).map(([domain, e]) => ({
|
|
782
898
|
domain,
|
|
783
899
|
...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
|
|
784
900
|
...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
|
|
785
|
-
}))
|
|
901
|
+
}));
|
|
902
|
+
return pickOne(setups, domainFilter, (s) => s.domain, {
|
|
786
903
|
flag: "domain",
|
|
787
904
|
source: "site.searchConsole"
|
|
788
905
|
});
|
|
@@ -815,7 +932,7 @@ function searchAuth(token, credential, cfg, input) {
|
|
|
815
932
|
quotaProject: project
|
|
816
933
|
} : token;
|
|
817
934
|
}
|
|
818
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
935
|
+
const sleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
819
936
|
/**
|
|
820
937
|
* Tell search engines a deploy happened.
|
|
821
938
|
*
|
|
@@ -941,13 +1058,16 @@ async function cmdSearchNotify(argv) {
|
|
|
941
1058
|
if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
|
|
942
1059
|
}
|
|
943
1060
|
}
|
|
944
|
-
if (p.searchConsole.enabled)
|
|
945
|
-
|
|
946
|
-
const r = await submitSitemap(searchAuth(await googleAccessToken(credential), credential, site, a.str("input")), p.searchConsole.siteUrl, sitemapUrl);
|
|
947
|
-
if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
|
|
1061
|
+
if (p.searchConsole.enabled) {
|
|
1062
|
+
if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
|
|
948
1063
|
else {
|
|
949
|
-
|
|
950
|
-
|
|
1064
|
+
const auth = searchAuth(await googleAccessToken(credential), credential, site, a.str("input"));
|
|
1065
|
+
const r = await submitSitemap(auth, p.searchConsole.siteUrl, sitemapUrl);
|
|
1066
|
+
if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
|
|
1067
|
+
else {
|
|
1068
|
+
console.error(`✖ Search Console: ${r.message}`);
|
|
1069
|
+
failed = true;
|
|
1070
|
+
}
|
|
951
1071
|
}
|
|
952
1072
|
}
|
|
953
1073
|
if (failed) process.exit(1);
|
|
@@ -1088,7 +1208,7 @@ async function cmdSearchSetup(argv) {
|
|
|
1088
1208
|
if (attempt < delays.length - 1) {
|
|
1089
1209
|
const wait = delays[attempt];
|
|
1090
1210
|
console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
|
|
1091
|
-
await sleep(wait);
|
|
1211
|
+
await sleep$1(wait);
|
|
1092
1212
|
}
|
|
1093
1213
|
}
|
|
1094
1214
|
result.verified = verified ? "yes" : "pending";
|
|
@@ -1513,7 +1633,8 @@ async function cmdAdsTags(argv) {
|
|
|
1513
1633
|
* with tracking off produces conversions no campaign gets credit for.
|
|
1514
1634
|
*/
|
|
1515
1635
|
async function cmdAdsLint(argv) {
|
|
1516
|
-
const
|
|
1636
|
+
const a = parse$1(argv, SITE_OPTS);
|
|
1637
|
+
const cfg = await siteConfig(a);
|
|
1517
1638
|
const properties = toAdProperties(cfg, void 0);
|
|
1518
1639
|
if (properties.length === 0) {
|
|
1519
1640
|
console.log("· no site.ads properties — nothing to check");
|
|
@@ -1551,6 +1672,506 @@ async function cmdAds(argv) {
|
|
|
1551
1672
|
}
|
|
1552
1673
|
}
|
|
1553
1674
|
|
|
1675
|
+
//#endregion
|
|
1676
|
+
//#region src/commands/crm.ts
|
|
1677
|
+
/**
|
|
1678
|
+
* `vitops crm` — the two things `site.crm` needs a human-in-the-loop for.
|
|
1679
|
+
*
|
|
1680
|
+
* `check` is the drift guard, and the reason this command exists at all:
|
|
1681
|
+
* HubSpot *rejects* a submission naming an unrecognised property, and Bigin
|
|
1682
|
+
* silently *ignores* one — so a typo'd `fieldMap`/`trackingFields` entry is
|
|
1683
|
+
* invisible in one CRM and fatal in the other, and nothing short of fetching
|
|
1684
|
+
* the live field list can tell you which. `setup` creates the missing
|
|
1685
|
+
* properties HubSpot's API allows creating; Bigin has no such endpoint, so it
|
|
1686
|
+
* always resolves to a manual reminder, matching how `ads setup` handles a
|
|
1687
|
+
* platform with no verification API. Jobber currently renders `fieldMap`/
|
|
1688
|
+
* `trackingFields` values as note-text lines rather than app-configured
|
|
1689
|
+
* custom fields (see `@getvitops/utils/crm`'s `jobber.ts` docblock for why
|
|
1690
|
+
* that surface is deferred), so there is nothing live to check yet — both
|
|
1691
|
+
* subcommands say so rather than guessing at an unverified API.
|
|
1692
|
+
*
|
|
1693
|
+
* Modelled on `commands/ads.ts`: read-and-create only, never destructive.
|
|
1694
|
+
*/
|
|
1695
|
+
/** Every CRM property name this site's config asserts exists, from both maps —
|
|
1696
|
+
* `fieldMap`/`trackingFields` values are property names, not the submitted
|
|
1697
|
+
* field names on the left. */
|
|
1698
|
+
function configuredProperties(crm) {
|
|
1699
|
+
return [...Object.values(crm.fieldMap ?? {}), ...Object.values(crm.trackingFields ?? {})];
|
|
1700
|
+
}
|
|
1701
|
+
async function hubspotContactProperties(accessToken) {
|
|
1702
|
+
const res = await fetch("https://api.hubapi.com/crm/v3/properties/contacts", { headers: { authorization: `Bearer ${accessToken}` } });
|
|
1703
|
+
if (!res.ok) fail(`HubSpot properties lookup failed (${res.status}): ${await res.text()}`);
|
|
1704
|
+
const json = await res.json();
|
|
1705
|
+
return new Set((json.results ?? []).map((p) => p.name));
|
|
1706
|
+
}
|
|
1707
|
+
async function biginContactFields(oauth, dataCenter) {
|
|
1708
|
+
const body = new URLSearchParams({
|
|
1709
|
+
grant_type: "refresh_token",
|
|
1710
|
+
client_id: oauth.clientId,
|
|
1711
|
+
client_secret: oauth.clientSecret,
|
|
1712
|
+
refresh_token: oauth.refreshToken
|
|
1713
|
+
});
|
|
1714
|
+
const tokenRes = await fetch(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {
|
|
1715
|
+
method: "POST",
|
|
1716
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1717
|
+
body: body.toString()
|
|
1718
|
+
});
|
|
1719
|
+
if (!tokenRes.ok) fail(`Zoho token exchange failed (${tokenRes.status}): ${await tokenRes.text()}`);
|
|
1720
|
+
const { access_token } = await tokenRes.json();
|
|
1721
|
+
if (!access_token) fail("Zoho token exchange returned no access_token");
|
|
1722
|
+
const res = await fetch(`https://${zohoApiHost(dataCenter)}/bigin/v2/settings/fields?module=Contacts`, { headers: { authorization: `Zoho-oauthtoken ${access_token}` } });
|
|
1723
|
+
if (!res.ok) fail(`Bigin fields lookup failed (${res.status}): ${await res.text()}`);
|
|
1724
|
+
const json = await res.json();
|
|
1725
|
+
return new Set((json.fields ?? []).map((f) => f.api_name));
|
|
1726
|
+
}
|
|
1727
|
+
/** What each provider's `check` needs to say when it doesn't fail outright.
|
|
1728
|
+
* Kept as a table rather than an `if/else if` chain so a fourth provider is
|
|
1729
|
+
* a missing entry (a type error), not a silently-wrong branch. */
|
|
1730
|
+
const CHECK_NOUNS = {
|
|
1731
|
+
hubspot: "properties",
|
|
1732
|
+
bigin: "fields",
|
|
1733
|
+
jobber: "fields"
|
|
1734
|
+
};
|
|
1735
|
+
const CHECK_REJECTION = {
|
|
1736
|
+
hubspot: "HubSpot will reject the whole submission",
|
|
1737
|
+
bigin: "Bigin will silently drop this value",
|
|
1738
|
+
jobber: "Jobber will silently drop this value"
|
|
1739
|
+
};
|
|
1740
|
+
/**
|
|
1741
|
+
* Fetch the live field/property vocabulary and report every `fieldMap`/
|
|
1742
|
+
* `trackingFields` target that doesn't exist there.
|
|
1743
|
+
*/
|
|
1744
|
+
async function cmdCrmCheck(argv) {
|
|
1745
|
+
const a = parse$1(argv, SITE_OPTS);
|
|
1746
|
+
const crm = (await siteConfig(a)).site.crm;
|
|
1747
|
+
if (!crm) {
|
|
1748
|
+
console.log("· no site.crm — nothing to check");
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
if (crm.enabled === false) {
|
|
1752
|
+
console.log("· site.crm.enabled is false — nothing to check");
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const wanted = configuredProperties(crm);
|
|
1756
|
+
if (wanted.length === 0) {
|
|
1757
|
+
console.log("· site.crm has no fieldMap/trackingFields entries — nothing to check");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
if (crm.provider === "jobber") {
|
|
1761
|
+
console.log("· no live check for Jobber yet — fieldMap/trackingFields values currently render as note-text lines, not app-configured custom fields, so nothing there can silently mismatch (see @getvitops/utils/crm jobber.ts docblock).");
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
let known;
|
|
1765
|
+
if (crm.provider === "hubspot") {
|
|
1766
|
+
const token = loadHubSpotToken();
|
|
1767
|
+
if (!token) fail("set VITOPS_HUBSPOT_ACCESS_TOKEN to check the live property list");
|
|
1768
|
+
known = await hubspotContactProperties(token);
|
|
1769
|
+
} else {
|
|
1770
|
+
const oauth = loadZohoOAuth();
|
|
1771
|
+
if (!oauth) fail("set VITOPS_ZOHO_CLIENT_ID / VITOPS_ZOHO_CLIENT_SECRET / VITOPS_ZOHO_REFRESH_TOKEN to check the live field list");
|
|
1772
|
+
known = await biginContactFields(oauth, crm.bigin.dataCenter);
|
|
1773
|
+
}
|
|
1774
|
+
const missing = wanted.filter((p) => !known.has(p));
|
|
1775
|
+
if (missing.length === 0) {
|
|
1776
|
+
console.log(`✓ ${wanted.length} mapped ${CHECK_NOUNS[crm.provider]} exist`);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
for (const p of missing) console.error(`✖ "${p}" is not a ${crm.provider === "hubspot" ? "HubSpot contact property" : "Bigin Contacts field"} — ${CHECK_REJECTION[crm.provider]}`);
|
|
1780
|
+
process.exit(1);
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* Create the HubSpot properties `fieldMap`/`trackingFields` name but don't
|
|
1784
|
+
* exist yet. Bigin has no property-creation API and Jobber's app-configured
|
|
1785
|
+
* custom fields aren't wired up yet (see the module docblock) — both resolve
|
|
1786
|
+
* to `blocked`/a manual reminder, matching `ads setup`'s handling of a
|
|
1787
|
+
* platform with no verification API.
|
|
1788
|
+
*/
|
|
1789
|
+
async function cmdCrmSetup(argv) {
|
|
1790
|
+
const a = parse$1(argv, {
|
|
1791
|
+
...SITE_OPTS,
|
|
1792
|
+
dry: {
|
|
1793
|
+
type: "boolean",
|
|
1794
|
+
default: false
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
const crm = (await siteConfig(a)).site.crm;
|
|
1798
|
+
if (!crm) fail("no site.crm configured — nothing to set up");
|
|
1799
|
+
const wanted = configuredProperties(crm);
|
|
1800
|
+
if (crm.provider === "jobber") {
|
|
1801
|
+
console.log("· Jobber app-configured custom fields are not wired up yet — fieldMap/trackingFields values render as note-text lines instead, which need no setup. If you do want them as real Jobber custom fields, create them manually in Jobber's Developer Center first.");
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (crm.provider === "bigin") {
|
|
1805
|
+
if (wanted.length === 0) {
|
|
1806
|
+
console.log("· no fieldMap/trackingFields entries to check against Bigin");
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
console.log("? Bigin has no API to create Contacts fields. Create these manually in Zoho Bigin's field editor, then re-run `vitops crm check`:\n" + wanted.map((p) => ` - ${p}`).join("\n"));
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const token = loadHubSpotToken();
|
|
1813
|
+
if (!token) fail("set VITOPS_HUBSPOT_ACCESS_TOKEN to create HubSpot properties");
|
|
1814
|
+
const known = await hubspotContactProperties(token);
|
|
1815
|
+
const missing = wanted.filter((p) => !known.has(p));
|
|
1816
|
+
if (missing.length === 0) {
|
|
1817
|
+
console.log("✓ every mapped property already exists");
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (a.flag("dry")) {
|
|
1821
|
+
console.log(`would create ${missing.length} HubSpot contact propert${missing.length === 1 ? "y" : "ies"}:`);
|
|
1822
|
+
for (const p of missing) console.log(` + ${p}`);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
for (const p of missing) {
|
|
1826
|
+
const res = await fetch("https://api.hubapi.com/crm/v3/properties/contacts", {
|
|
1827
|
+
method: "POST",
|
|
1828
|
+
headers: {
|
|
1829
|
+
authorization: `Bearer ${token}`,
|
|
1830
|
+
"content-type": "application/json"
|
|
1831
|
+
},
|
|
1832
|
+
body: JSON.stringify({
|
|
1833
|
+
name: p,
|
|
1834
|
+
label: p,
|
|
1835
|
+
type: "string",
|
|
1836
|
+
fieldType: "text",
|
|
1837
|
+
groupName: "contactinformation"
|
|
1838
|
+
})
|
|
1839
|
+
});
|
|
1840
|
+
if (!res.ok) {
|
|
1841
|
+
console.error(`✖ ${p}: ${res.status} ${await res.text()}`);
|
|
1842
|
+
process.exit(1);
|
|
1843
|
+
}
|
|
1844
|
+
console.log(`✓ created ${p}`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
async function cmdCrm(argv) {
|
|
1848
|
+
const [sub, ...rest] = argv;
|
|
1849
|
+
switch (sub) {
|
|
1850
|
+
case "check": return cmdCrmCheck(rest);
|
|
1851
|
+
case "setup": return cmdCrmSetup(rest);
|
|
1852
|
+
case void 0:
|
|
1853
|
+
case "-h":
|
|
1854
|
+
case "--help":
|
|
1855
|
+
console.log(CRM_HELP);
|
|
1856
|
+
return;
|
|
1857
|
+
default: fail(`unknown "crm" subcommand "${sub}" (expected: check | setup). Try: vitops --help`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/commands/email.ts
|
|
1863
|
+
/**
|
|
1864
|
+
* `vitops email` — connect `site.notifications.email.from` to whether its domain
|
|
1865
|
+
* is actually onboarded onto an outbound email provider.
|
|
1866
|
+
*
|
|
1867
|
+
* The silent failure this exists to catch: `EMAIL.send()` throws
|
|
1868
|
+
* `E_SENDER_NOT_VERIFIED` at runtime with nothing at build or deploy time saying
|
|
1869
|
+
* a word, because nothing previously checked the `from` domain against Cloudflare
|
|
1870
|
+
* Email Sending's actual onboarding state. Two subcommands, split by what they
|
|
1871
|
+
* do: `setup` onboards a domain and waits for its DNS to resolve; `check` is the
|
|
1872
|
+
* non-blocking deploy guard.
|
|
1873
|
+
*/
|
|
1874
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1875
|
+
/**
|
|
1876
|
+
* Every `from` domain worth checking: the explicit config value first, its
|
|
1877
|
+
* derived `noreply@<canonical>` default second, and a best-effort scan of
|
|
1878
|
+
* `--src` as a supplement — never the primary source, since a computed value in
|
|
1879
|
+
* source is reported as dynamic rather than guessed at (see `email/scan.ts`).
|
|
1880
|
+
*/
|
|
1881
|
+
function gatherCandidates(cfg, srcDir) {
|
|
1882
|
+
const candidates = [];
|
|
1883
|
+
const dynamicNotes = [];
|
|
1884
|
+
const channel = normalizeEmailChannel(cfg.site.notifications?.email);
|
|
1885
|
+
const canonical = cfg.site.domains?.canonical;
|
|
1886
|
+
const address = resolveSender(channel ?? { provider: "cloudflare" }, { canonical });
|
|
1887
|
+
if (address) {
|
|
1888
|
+
const domain = address.split("@")[1];
|
|
1889
|
+
if (domain) candidates.push({
|
|
1890
|
+
address,
|
|
1891
|
+
domain,
|
|
1892
|
+
source: channel?.from ? "config" : "derived"
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
if (existsSync(srcDir)) {
|
|
1896
|
+
const { addresses, dynamic } = collectFromAddresses(scanFiles(srcDir));
|
|
1897
|
+
for (const a of addresses) {
|
|
1898
|
+
const domain = a.split("@")[1];
|
|
1899
|
+
if (domain && !candidates.some((c) => c.address === a)) candidates.push({
|
|
1900
|
+
address: a,
|
|
1901
|
+
domain,
|
|
1902
|
+
source: "scan"
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
for (const d of dynamic) dynamicNotes.push(`! ${d.file}:${d.line}: from is computed (\`${d.expr}\`) — not checked`);
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
candidates,
|
|
1909
|
+
dynamicNotes
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
/** Observe each candidate domain's zone, keyed by the domain itself (not the zone name). */
|
|
1913
|
+
async function observeZones(token, domains) {
|
|
1914
|
+
const states = /* @__PURE__ */ new Map();
|
|
1915
|
+
const failures = [];
|
|
1916
|
+
if (!token) return {
|
|
1917
|
+
states,
|
|
1918
|
+
failures
|
|
1919
|
+
};
|
|
1920
|
+
for (const domain of domains) {
|
|
1921
|
+
const zone = await findZoneForHost(token, domain);
|
|
1922
|
+
if (!zone.ok || !zone.zoneId) {
|
|
1923
|
+
failures.push(`${domain}: ${zone.message}`);
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
const res = await listSendingSubdomains(token, zone.zoneId);
|
|
1927
|
+
if (!res.ok) {
|
|
1928
|
+
failures.push(`${domain}: ${res.message}`);
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
states.set(domain, {
|
|
1932
|
+
zoneId: zone.zoneId,
|
|
1933
|
+
subdomains: res.subdomains
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
return {
|
|
1937
|
+
states,
|
|
1938
|
+
failures
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
async function confirm(question) {
|
|
1942
|
+
const rl = createInterface({
|
|
1943
|
+
input: process.stdin,
|
|
1944
|
+
output: process.stdout
|
|
1945
|
+
});
|
|
1946
|
+
try {
|
|
1947
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
1948
|
+
return answer === "y" || answer === "yes";
|
|
1949
|
+
} finally {
|
|
1950
|
+
rl.close();
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Onboard a `from` domain and wait for its DNS to resolve.
|
|
1955
|
+
*
|
|
1956
|
+
* Idempotent by construction — a re-run of a fully-onboarded domain is all
|
|
1957
|
+
* skips. `--dry` prints the plan and mutates nothing (and needs no credential,
|
|
1958
|
+
* planning from scratch); `--check` reports drift and exits non-zero.
|
|
1959
|
+
*/
|
|
1960
|
+
async function cmdEmailSetup(argv) {
|
|
1961
|
+
const a = parse$1(argv, {
|
|
1962
|
+
...SITE_OPTS,
|
|
1963
|
+
src: {
|
|
1964
|
+
type: "string",
|
|
1965
|
+
default: "src"
|
|
1966
|
+
},
|
|
1967
|
+
dry: {
|
|
1968
|
+
type: "boolean",
|
|
1969
|
+
default: false
|
|
1970
|
+
},
|
|
1971
|
+
check: {
|
|
1972
|
+
type: "boolean",
|
|
1973
|
+
default: false
|
|
1974
|
+
},
|
|
1975
|
+
"no-prompt": {
|
|
1976
|
+
type: "boolean",
|
|
1977
|
+
default: false
|
|
1978
|
+
},
|
|
1979
|
+
timeout: {
|
|
1980
|
+
type: "string",
|
|
1981
|
+
default: "5"
|
|
1982
|
+
}
|
|
1983
|
+
}, true);
|
|
1984
|
+
const cfg = await loadConfig(a.path("input"), a.str("site-env"));
|
|
1985
|
+
const explicitDomain = a.positionals[0];
|
|
1986
|
+
const dry = a.flag("dry");
|
|
1987
|
+
const check = a.flag("check");
|
|
1988
|
+
const { candidates, dynamicNotes } = explicitDomain ? {
|
|
1989
|
+
candidates: [{
|
|
1990
|
+
address: `noreply@${explicitDomain}`,
|
|
1991
|
+
domain: explicitDomain,
|
|
1992
|
+
source: "config"
|
|
1993
|
+
}],
|
|
1994
|
+
dynamicNotes: []
|
|
1995
|
+
} : gatherCandidates(cfg, a.path("src"));
|
|
1996
|
+
if (candidates.length === 0) fail("no from domain to check — set site.notifications.email.from or site.domains.canonical, or pass a domain: vitops email setup <domain>");
|
|
1997
|
+
const cfToken = loadCloudflareToken();
|
|
1998
|
+
if (!cfToken && !dry) fail(CLOUDFLARE_TOKEN_HINT);
|
|
1999
|
+
const { states, failures } = await observeZones(cfToken, [...new Set(candidates.map((c) => c.domain))]);
|
|
2000
|
+
const emailPlan = planSendingDomains({
|
|
2001
|
+
candidates,
|
|
2002
|
+
provider: "cloudflare"
|
|
2003
|
+
}, states);
|
|
2004
|
+
if (dry || check) {
|
|
2005
|
+
console.log(formatPlan$5(emailPlan));
|
|
2006
|
+
for (const n of dynamicNotes) console.log(n);
|
|
2007
|
+
for (const f of failures) console.error(`! ${f}`);
|
|
2008
|
+
if (check && hasDrift$2(emailPlan)) {
|
|
2009
|
+
console.error("\n✖ a from domain is not fully onboarded");
|
|
2010
|
+
process.exit(1);
|
|
2011
|
+
}
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
const interactive = !a.flag("no-prompt") && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
2015
|
+
const timeoutMs = Number(a.str("timeout")) * 1e3;
|
|
2016
|
+
const results = [];
|
|
2017
|
+
let failed = false;
|
|
2018
|
+
for (const d of emailPlan.domains) {
|
|
2019
|
+
if (d.onboard.action === "skip") {
|
|
2020
|
+
results.push({
|
|
2021
|
+
domain: d.domain,
|
|
2022
|
+
onboard: "present",
|
|
2023
|
+
reminders: []
|
|
2024
|
+
});
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (d.onboard.action === "blocked") {
|
|
2028
|
+
console.error(`? ${d.domain}: ${d.onboard.detail}`);
|
|
2029
|
+
results.push({
|
|
2030
|
+
domain: d.domain,
|
|
2031
|
+
onboard: "blocked",
|
|
2032
|
+
reminders: []
|
|
2033
|
+
});
|
|
2034
|
+
failed = true;
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
console.log(`\n${d.domain}: ${d.onboard.detail}`);
|
|
2038
|
+
if (interactive && !await confirm(`Enable Email Sending for "${d.domain}"?`)) {
|
|
2039
|
+
results.push({
|
|
2040
|
+
domain: d.domain,
|
|
2041
|
+
onboard: "blocked",
|
|
2042
|
+
reminders: ["skipped by operator"]
|
|
2043
|
+
});
|
|
2044
|
+
continue;
|
|
2045
|
+
}
|
|
2046
|
+
const zone = await findZoneForHost(cfToken, d.domain);
|
|
2047
|
+
if (!zone.ok || !zone.zoneId) {
|
|
2048
|
+
console.error(`✖ ${d.domain}: ${zone.message}`);
|
|
2049
|
+
results.push({
|
|
2050
|
+
domain: d.domain,
|
|
2051
|
+
onboard: "failed",
|
|
2052
|
+
reminders: []
|
|
2053
|
+
});
|
|
2054
|
+
failed = true;
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
const enabled = await enableSending(cfToken, zone.zoneId, d.domain);
|
|
2058
|
+
if (!enabled.ok) {
|
|
2059
|
+
console.error(`✖ ${d.domain}: ${enabled.message}`);
|
|
2060
|
+
results.push({
|
|
2061
|
+
domain: d.domain,
|
|
2062
|
+
onboard: "failed",
|
|
2063
|
+
reminders: []
|
|
2064
|
+
});
|
|
2065
|
+
failed = true;
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
console.log(`✓ ${d.domain}: enabled`);
|
|
2069
|
+
const reminders = [];
|
|
2070
|
+
if (enabled.tag) {
|
|
2071
|
+
const dns = await sendingDns(cfToken, zone.zoneId, enabled.tag);
|
|
2072
|
+
const expected = dns.records.filter((r) => r.type === "TXT");
|
|
2073
|
+
if (dns.ok && expected.length) {
|
|
2074
|
+
const schedule = backoffSchedule$1();
|
|
2075
|
+
let pending = expected;
|
|
2076
|
+
for (let attempt = 0; attempt < schedule.length && pending.length; attempt++) {
|
|
2077
|
+
await sleep(Math.min(schedule[attempt], timeoutMs));
|
|
2078
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
2079
|
+
for (const name of new Set(pending.map((r) => r.name))) {
|
|
2080
|
+
const r = await resolveTxt(name);
|
|
2081
|
+
if (r.ok) resolved.set(name, r.contents);
|
|
2082
|
+
}
|
|
2083
|
+
pending = dnsStatus(pending, resolved).filter((s) => !s.present).map((s) => s.record);
|
|
2084
|
+
console.log(` DNS: ${expected.length - pending.length}/${expected.length} records resolving (attempt ${attempt + 1}/${schedule.length})`);
|
|
2085
|
+
}
|
|
2086
|
+
if (pending.length) reminders.push(`${pending.length} DNS record(s) not yet resolving — check again shortly.`);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
results.push({
|
|
2090
|
+
domain: d.domain,
|
|
2091
|
+
onboard: "created",
|
|
2092
|
+
reminders
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
console.log(`\n${formatSummary$2(results)}`);
|
|
2096
|
+
for (const n of dynamicNotes) console.log(n);
|
|
2097
|
+
for (const f of failures) console.error(`! ${f}`);
|
|
2098
|
+
if (failed) process.exit(1);
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* The non-blocking deploy guard: warn when a `from` domain isn't onboarded,
|
|
2102
|
+
* exit 0 by default. `--strict` makes it a real gate.
|
|
2103
|
+
*
|
|
2104
|
+
* Runs with no credential too — it then reports "not checked", exits 0, and a
|
|
2105
|
+
* CI job with no CLOUDFLARE_API_TOKEN never fails on the check itself.
|
|
2106
|
+
*/
|
|
2107
|
+
async function cmdEmailCheck(argv) {
|
|
2108
|
+
const a = parse$1(argv, {
|
|
2109
|
+
...SITE_OPTS,
|
|
2110
|
+
src: {
|
|
2111
|
+
type: "string",
|
|
2112
|
+
default: "src"
|
|
2113
|
+
},
|
|
2114
|
+
strict: {
|
|
2115
|
+
type: "boolean",
|
|
2116
|
+
default: false
|
|
2117
|
+
},
|
|
2118
|
+
skip: {
|
|
2119
|
+
type: "boolean",
|
|
2120
|
+
default: false
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
if (a.flag("skip") || process.env["VITOPS_SKIP_EMAIL_CHECK"]) {
|
|
2124
|
+
console.log("· vitops email check: skipped (--skip / VITOPS_SKIP_EMAIL_CHECK)");
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const cfg = await siteConfig(a);
|
|
2128
|
+
const emailCfg = cfg.site.notifications?.email;
|
|
2129
|
+
if (typeof emailCfg === "object" && emailCfg.verifySender === false) {
|
|
2130
|
+
console.log("· vitops email check: skipped (site.notifications.email.verifySender is false)");
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const { candidates, dynamicNotes } = gatherCandidates(cfg, a.path("src"));
|
|
2134
|
+
if (candidates.length === 0) {
|
|
2135
|
+
console.log("· no from domain configured — nothing to check");
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const cfToken = loadCloudflareToken();
|
|
2139
|
+
if (!cfToken) {
|
|
2140
|
+
console.log("· vitops email check: not checked — no CLOUDFLARE_API_TOKEN");
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
const { states, failures } = await observeZones(cfToken, [...new Set(candidates.map((c) => c.domain))]);
|
|
2144
|
+
const emailPlan = planSendingDomains({
|
|
2145
|
+
candidates,
|
|
2146
|
+
provider: "cloudflare"
|
|
2147
|
+
}, states);
|
|
2148
|
+
for (const n of dynamicNotes) console.log(n);
|
|
2149
|
+
for (const f of failures) console.error(`! ${f}`);
|
|
2150
|
+
const blocked = blockedDomains(emailPlan);
|
|
2151
|
+
if (blocked.length === 0) {
|
|
2152
|
+
console.log(`✓ every from domain is onboarded`);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
for (const b of blocked) console.error(`! ${b.domain}: ${b.onboard.detail}`);
|
|
2156
|
+
if (a.flag("strict")) {
|
|
2157
|
+
console.error("\n✖ a from domain is not onboarded (--strict)");
|
|
2158
|
+
process.exit(1);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
async function cmdEmail(argv) {
|
|
2162
|
+
const [sub, ...rest] = argv;
|
|
2163
|
+
switch (sub) {
|
|
2164
|
+
case "setup": return cmdEmailSetup(rest);
|
|
2165
|
+
case "check": return cmdEmailCheck(rest);
|
|
2166
|
+
case void 0:
|
|
2167
|
+
case "-h":
|
|
2168
|
+
case "--help":
|
|
2169
|
+
console.log(EMAIL_HELP);
|
|
2170
|
+
return;
|
|
2171
|
+
default: fail(`unknown "email" subcommand "${sub}" (expected: setup | check). Try: vitops --help`);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
1554
2175
|
//#endregion
|
|
1555
2176
|
//#region src/commands/setup.ts
|
|
1556
2177
|
/**
|
|
@@ -1659,7 +2280,7 @@ async function cmdSetupRepo(argv) {
|
|
|
1659
2280
|
const dry = a.flag("dry");
|
|
1660
2281
|
const check = a.flag("check");
|
|
1661
2282
|
if (dry || check) {
|
|
1662
|
-
console.log(formatPlan$
|
|
2283
|
+
console.log(formatPlan$6(p));
|
|
1663
2284
|
if (check && (p.repo.action !== "skip" || p.branches.action !== "skip" || p.defaultBranch.action !== "skip")) {
|
|
1664
2285
|
console.error("\n✖ repo/branch setup is not complete");
|
|
1665
2286
|
process.exit(1);
|
|
@@ -1743,7 +2364,7 @@ async function cmdSetupRepo(argv) {
|
|
|
1743
2364
|
status: "blocked",
|
|
1744
2365
|
detail: "no GitHub repo yet — run this again once \"repo\" succeeds"
|
|
1745
2366
|
});
|
|
1746
|
-
console.log(formatSummary$
|
|
2367
|
+
console.log(formatSummary$3(results));
|
|
1747
2368
|
if (results.some((r) => r.status === "failed")) process.exit(1);
|
|
1748
2369
|
}
|
|
1749
2370
|
async function cmdSetupHosting(argv) {
|
|
@@ -1784,7 +2405,7 @@ async function cmdSetupHosting(argv) {
|
|
|
1784
2405
|
}
|
|
1785
2406
|
if (!state.wranglerAuthed && !dry) fail("wrangler is not logged in — run `npx wrangler login` first");
|
|
1786
2407
|
if (dry || check) {
|
|
1787
|
-
console.log(formatPlan$
|
|
2408
|
+
console.log(formatPlan$6(p));
|
|
1788
2409
|
if (check && (p.d1.action !== "skip" || p.r2.action !== "skip")) {
|
|
1789
2410
|
console.error("\n✖ hosting resources are not fully provisioned");
|
|
1790
2411
|
process.exit(1);
|
|
@@ -1853,7 +2474,7 @@ async function cmdSetupHosting(argv) {
|
|
|
1853
2474
|
status: "blocked",
|
|
1854
2475
|
detail: p.secrets["CLOUDFLARE_API_TOKEN"]?.detail ?? "create it by hand"
|
|
1855
2476
|
});
|
|
1856
|
-
console.log(formatSummary$
|
|
2477
|
+
console.log(formatSummary$3(results));
|
|
1857
2478
|
for (const n of p.notes) console.log(`! ${n}`);
|
|
1858
2479
|
if (results.some((r) => r.status === "failed")) process.exit(1);
|
|
1859
2480
|
}
|
|
@@ -1877,13 +2498,14 @@ async function cmdSetupDomain(argv) {
|
|
|
1877
2498
|
const customDomain = customDomainMatch?.[1] ?? (a.opt("domain") ? void 0 : void 0);
|
|
1878
2499
|
const cfg = await loadConfig(a.path("input"), a.str("site-env"));
|
|
1879
2500
|
const env = cfg.site.environments[a.str("site-env")] ?? cfg.site.environments["production"];
|
|
1880
|
-
const
|
|
2501
|
+
const state = {
|
|
1881
2502
|
wranglerDev: !raw.includes("\"routes\"") || Boolean(customDomainMatch) === false,
|
|
1882
2503
|
...customDomain ? { customDomain } : {},
|
|
1883
2504
|
...env?.url ? { configuredUrl: env.url } : {},
|
|
1884
2505
|
...cfg.site.domains?.canonical ? { canonical: cfg.site.domains.canonical } : {},
|
|
1885
2506
|
...env?.robots ? { robots: env.robots } : {}
|
|
1886
|
-
}
|
|
2507
|
+
};
|
|
2508
|
+
const step = planDomainConsistency(state);
|
|
1887
2509
|
console.log(`${step.action === "skip" ? "·" : "?"} domain consistency ${step.detail}`);
|
|
1888
2510
|
if (a.opt("domain")) console.log(`\nCutover is never applied by this command — see SETUP.html stage 4b. To cut over: uncomment routes in wrangler.jsonc with "pattern": "${a.opt("domain")}", deploy, then update site.domains.canonical and site.environments.${a.str("site-env")}.url to match.`);
|
|
1889
2511
|
if (a.flag("check") && step.action !== "skip") process.exit(1);
|
|
@@ -2437,7 +3059,7 @@ function lintCss(files, format) {
|
|
|
2437
3059
|
* A consumer's markdown content can hold real markup, so this does give up a
|
|
2438
3060
|
* little coverage; that is the right side to err on for a heuristic.
|
|
2439
3061
|
*/
|
|
2440
|
-
const MARKUP_EXT = new Set([
|
|
3062
|
+
const MARKUP_EXT = /* @__PURE__ */ new Set([
|
|
2441
3063
|
".astro",
|
|
2442
3064
|
".html",
|
|
2443
3065
|
".htm",
|
|
@@ -2884,6 +3506,7 @@ function lintSource(files, v, format) {
|
|
|
2884
3506
|
* vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
|
|
2885
3507
|
* vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
|
|
2886
3508
|
* vitops setup repo | hosting | domain | search | ads | all [--dry] [--check]
|
|
3509
|
+
* vitops email setup [domain] [--dry] [--check] | check [--strict] [--skip]
|
|
2887
3510
|
* vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
|
|
2888
3511
|
*
|
|
2889
3512
|
* Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
|
|
@@ -2893,23 +3516,23 @@ function lintSource(files, v, format) {
|
|
|
2893
3516
|
* command groups big enough, and self-contained enough, to earn their own file.
|
|
2894
3517
|
* Everything else stays here.
|
|
2895
3518
|
*/
|
|
2896
|
-
const FORMATS = new Set([
|
|
3519
|
+
const FORMATS = /* @__PURE__ */ new Set([
|
|
2897
3520
|
"bricks",
|
|
2898
3521
|
"css",
|
|
2899
3522
|
"tailwind",
|
|
2900
3523
|
"design"
|
|
2901
3524
|
]);
|
|
2902
|
-
const LINT_FORMATS = new Set([
|
|
3525
|
+
const LINT_FORMATS = /* @__PURE__ */ new Set([
|
|
2903
3526
|
"bricks",
|
|
2904
3527
|
"css",
|
|
2905
3528
|
"tailwind"
|
|
2906
3529
|
]);
|
|
2907
|
-
const LEGAL_DOCS = new Set([
|
|
3530
|
+
const LEGAL_DOCS = /* @__PURE__ */ new Set([
|
|
2908
3531
|
"privacy",
|
|
2909
3532
|
"terms",
|
|
2910
3533
|
"cookies"
|
|
2911
3534
|
]);
|
|
2912
|
-
const LEGAL_OUTPUTS = new Set([
|
|
3535
|
+
const LEGAL_OUTPUTS = /* @__PURE__ */ new Set([
|
|
2913
3536
|
"md",
|
|
2914
3537
|
"html",
|
|
2915
3538
|
"portable-text"
|
|
@@ -3098,7 +3721,7 @@ async function cmdFavicon(argv) {
|
|
|
3098
3721
|
fail(msg(err));
|
|
3099
3722
|
}
|
|
3100
3723
|
}
|
|
3101
|
-
const OUTPUT_KINDS = new Set([
|
|
3724
|
+
const OUTPUT_KINDS = /* @__PURE__ */ new Set([
|
|
3102
3725
|
"webm",
|
|
3103
3726
|
"mp4",
|
|
3104
3727
|
"poster"
|
|
@@ -3395,7 +4018,8 @@ async function cmdRobots(argv) {
|
|
|
3395
4018
|
}
|
|
3396
4019
|
});
|
|
3397
4020
|
const siteEnv = a.str("site-env");
|
|
3398
|
-
const
|
|
4021
|
+
const site = await loadConfig(a.path("input"), siteEnv);
|
|
4022
|
+
const content = renderRobotsTxt(site, { env: siteEnv });
|
|
3399
4023
|
if (!a.opt("out")) {
|
|
3400
4024
|
process.stdout.write(content);
|
|
3401
4025
|
return;
|
|
@@ -3527,7 +4151,8 @@ function cliVersion() {
|
|
|
3527
4151
|
}
|
|
3528
4152
|
}
|
|
3529
4153
|
function generatorAssetsDir() {
|
|
3530
|
-
|
|
4154
|
+
const pkg = createRequire(import.meta.url).resolve("@getvitops/generator/package.json");
|
|
4155
|
+
return join(dirname(pkg), "assets");
|
|
3531
4156
|
}
|
|
3532
4157
|
const AGENTS_START = "<!-- vitops:start -->";
|
|
3533
4158
|
const AGENTS_END = "<!-- vitops:end -->";
|
|
@@ -3556,7 +4181,8 @@ function cmdDocs(argv) {
|
|
|
3556
4181
|
return;
|
|
3557
4182
|
}
|
|
3558
4183
|
if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
|
|
3559
|
-
const
|
|
4184
|
+
const ds = loadDesignSystem(resolve(values.input), values.theme);
|
|
4185
|
+
const docs = generateDocs(ds, generatorAssetsDir());
|
|
3560
4186
|
const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
|
|
3561
4187
|
process.stdout.write(paths.map((p) => docs[p]).join("\n"));
|
|
3562
4188
|
}
|
|
@@ -3603,7 +4229,7 @@ function cmdLint(argv) {
|
|
|
3603
4229
|
"icon",
|
|
3604
4230
|
"border"
|
|
3605
4231
|
]).map((u) => u.cls);
|
|
3606
|
-
const LINTABLE = new Set([
|
|
4232
|
+
const LINTABLE = /* @__PURE__ */ new Set([
|
|
3607
4233
|
".astro",
|
|
3608
4234
|
".html",
|
|
3609
4235
|
".js",
|
|
@@ -3836,6 +4462,8 @@ async function main() {
|
|
|
3836
4462
|
case "icons": return cmdIcons(rest);
|
|
3837
4463
|
case "search": return cmdSearch(rest);
|
|
3838
4464
|
case "ads": return cmdAds(rest);
|
|
4465
|
+
case "crm": return cmdCrm(rest);
|
|
4466
|
+
case "email": return cmdEmail(rest);
|
|
3839
4467
|
case "setup": return cmdSetup(rest);
|
|
3840
4468
|
case "media": return cmdMedia(rest);
|
|
3841
4469
|
default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
|