@getvitops/cli 8.0.1 → 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 +247 -14
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1138 -22
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -6
package/dist/cli.mjs
CHANGED
|
@@ -14,6 +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";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
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";
|
|
17
22
|
|
|
18
23
|
//#region src/args.ts
|
|
19
24
|
/**
|
|
@@ -294,6 +299,35 @@ function loadSearchCredential() {
|
|
|
294
299
|
}
|
|
295
300
|
/** Both spellings, for an error that has to tell you what to actually set. */
|
|
296
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
|
+
}
|
|
297
331
|
|
|
298
332
|
//#endregion
|
|
299
333
|
//#region src/help.ts
|
|
@@ -326,6 +360,8 @@ Usage:
|
|
|
326
360
|
vitops icons [options] Report which icons your source uses, and build the sprite
|
|
327
361
|
vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
|
|
328
362
|
vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
|
|
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)
|
|
329
365
|
vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
|
|
330
366
|
|
|
331
367
|
Generate options:
|
|
@@ -574,6 +610,128 @@ Ads lint options:
|
|
|
574
610
|
off (every conversion arrives unattributed), and a configured property with no
|
|
575
611
|
tag ID. Exits non-zero on a finding.
|
|
576
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
|
+
|
|
640
|
+
Setup repo options:
|
|
641
|
+
--dry Print the plan and exit
|
|
642
|
+
--check Report drift/blocked steps and exit non-zero, mutating nothing
|
|
643
|
+
--public Create the repo public (default: private)
|
|
644
|
+
--platform <p> cloudflare (default) | node — shown for parity with the hosting stage's plan
|
|
645
|
+
--no-dev-resources Same — this stage never provisions D1/R2 either way
|
|
646
|
+
|
|
647
|
+
Creates the GitHub repo (via \`gh\`), pushes main + dev, makes dev the default
|
|
648
|
+
branch, and requests main branch protection + the production/dev environments.
|
|
649
|
+
Idempotent — a re-run of a fully set-up repo is all skips. Branch protection and
|
|
650
|
+
environment reviewers 403 on a private repo without GitHub Pro/Team; that is
|
|
651
|
+
reported as blocked, with the manual Settings path, never as a failure.
|
|
652
|
+
|
|
653
|
+
Setup hosting options:
|
|
654
|
+
--dry Print the plan and exit
|
|
655
|
+
--check Report drift/blocked steps and exit non-zero, mutating nothing
|
|
656
|
+
--platform <p> cloudflare (default) | node
|
|
657
|
+
--no-dev-resources Skip D1/R2 — a pure static Workers Assets project has
|
|
658
|
+
nothing to provision, just the account id + token
|
|
659
|
+
|
|
660
|
+
Provisions the isolated dev D1 database + R2 bucket (cloudflare) and sets the
|
|
661
|
+
DEV_D1_ID / CLOUDFLARE_ACCOUNT_ID repo secrets, once \`vitops setup repo\` has run.
|
|
662
|
+
CLOUDFLARE_API_TOKEN is always reported as blocked — no API can create one; see
|
|
663
|
+
the printed dashboard link. Requires \`npx wrangler login\` first.
|
|
664
|
+
|
|
665
|
+
Setup domain options:
|
|
666
|
+
-i, --input <path> Site config (default: ./site.json)
|
|
667
|
+
--site-env <env> Environment to check (default: production)
|
|
668
|
+
--dry Print the plan and exit
|
|
669
|
+
--check Exit non-zero if the domain state is inconsistent
|
|
670
|
+
--domain <apex> Print the manual cutover steps for this apex
|
|
671
|
+
|
|
672
|
+
Plans and checks the consistency between wrangler.jsonc's workers.dev/custom-domain
|
|
673
|
+
state and site.json's environments/domains/robots — it NEVER edits wrangler.jsonc
|
|
674
|
+
or deploys. Cutover overwrites the zone's DNS record and can take an existing live
|
|
675
|
+
site down; that step is manual, on purpose. See SETUP.html's "Domain strategy" stage.
|
|
676
|
+
|
|
677
|
+
Setup search options:
|
|
678
|
+
Forwards every option to \`vitops search setup\` unchanged — see "Search setup
|
|
679
|
+
options:" above.
|
|
680
|
+
|
|
681
|
+
Setup ads options:
|
|
682
|
+
Forwards every option to \`vitops ads setup\` unchanged — see "Ads setup options:"
|
|
683
|
+
above.
|
|
684
|
+
|
|
685
|
+
Setup all options:
|
|
686
|
+
--dry Print every stage's plan and exit
|
|
687
|
+
--check Report drift across every stage and exit non-zero
|
|
688
|
+
--public Create the repo public, for the repo stage
|
|
689
|
+
--platform <p> cloudflare (default) | node, for the repo + hosting stages
|
|
690
|
+
--no-dev-resources Skip D1/R2 in the hosting stage (repo stage never provisions them)
|
|
691
|
+
|
|
692
|
+
Runs repo, hosting, search, ads in order. Stops at the first stage that fails;
|
|
693
|
+
a blocked step (the API token, GitHub Pro/Team gating) is reported, not fatal.
|
|
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
|
+
|
|
577
735
|
Media options:
|
|
578
736
|
--raw <dir> Directory of unprocessed video, walked recursively
|
|
579
737
|
(default: ./raw)
|
|
@@ -615,6 +773,32 @@ const ADS_HELP = `vitops ads — link this site to its ad properties
|
|
|
615
773
|
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
616
774
|
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
617
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
|
+
|
|
790
|
+
Run \`vitops --help\` for the full option list for each subcommand.
|
|
791
|
+
`;
|
|
792
|
+
const SETUP_HELP$1 = `vitops setup — the scripted path from an empty scaffold to a live site
|
|
793
|
+
|
|
794
|
+
vitops setup repo [opts] GitHub repo + branch policy (main protected, dev open)
|
|
795
|
+
vitops setup hosting [opts] Dev D1 + R2 + repo secrets (cloudflare) or a Node target
|
|
796
|
+
vitops setup domain [opts] Plan/check the workers.dev-vs-custom-domain consistency
|
|
797
|
+
(never applies the cutover — see SETUP.html)
|
|
798
|
+
vitops setup search [opts] Forwards to \`vitops search setup\`
|
|
799
|
+
vitops setup ads [opts] Forwards to \`vitops ads setup\`
|
|
800
|
+
vitops setup all [opts] Run repo, hosting, search, ads in order
|
|
801
|
+
|
|
618
802
|
Run \`vitops --help\` for the full option list for each subcommand.
|
|
619
803
|
`;
|
|
620
804
|
/**
|
|
@@ -636,6 +820,9 @@ const COMMANDS = [
|
|
|
636
820
|
"icons",
|
|
637
821
|
"search",
|
|
638
822
|
"ads",
|
|
823
|
+
"crm",
|
|
824
|
+
"setup",
|
|
825
|
+
"email",
|
|
639
826
|
"media"
|
|
640
827
|
];
|
|
641
828
|
/**
|
|
@@ -665,7 +852,7 @@ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
|
|
|
665
852
|
/** The help text to print for `vitops <command> [sub] --help`. */
|
|
666
853
|
function helpFor(command, argv) {
|
|
667
854
|
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
|
|
668
|
-
return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? SEARCH_HELP : command === "ads" ? ADS_HELP : 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;
|
|
669
856
|
}
|
|
670
857
|
|
|
671
858
|
//#endregion
|
|
@@ -707,11 +894,12 @@ function toIndexingConfig(cfg, siteEnv) {
|
|
|
707
894
|
*/
|
|
708
895
|
function toSearchConsoleSetup(cfg, domainFilter) {
|
|
709
896
|
const entries = cfg.site.searchConsole ?? {};
|
|
710
|
-
|
|
897
|
+
const setups = Object.entries(entries).map(([domain, e]) => ({
|
|
711
898
|
domain,
|
|
712
899
|
...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
|
|
713
900
|
...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
|
|
714
|
-
}))
|
|
901
|
+
}));
|
|
902
|
+
return pickOne(setups, domainFilter, (s) => s.domain, {
|
|
715
903
|
flag: "domain",
|
|
716
904
|
source: "site.searchConsole"
|
|
717
905
|
});
|
|
@@ -744,7 +932,7 @@ function searchAuth(token, credential, cfg, input) {
|
|
|
744
932
|
quotaProject: project
|
|
745
933
|
} : token;
|
|
746
934
|
}
|
|
747
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
935
|
+
const sleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
748
936
|
/**
|
|
749
937
|
* Tell search engines a deploy happened.
|
|
750
938
|
*
|
|
@@ -870,13 +1058,16 @@ async function cmdSearchNotify(argv) {
|
|
|
870
1058
|
if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
|
|
871
1059
|
}
|
|
872
1060
|
}
|
|
873
|
-
if (p.searchConsole.enabled)
|
|
874
|
-
|
|
875
|
-
const r = await submitSitemap(searchAuth(await googleAccessToken(credential), credential, site, a.str("input")), p.searchConsole.siteUrl, sitemapUrl);
|
|
876
|
-
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})`);
|
|
877
1063
|
else {
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}
|
|
880
1071
|
}
|
|
881
1072
|
}
|
|
882
1073
|
if (failed) process.exit(1);
|
|
@@ -1017,7 +1208,7 @@ async function cmdSearchSetup(argv) {
|
|
|
1017
1208
|
if (attempt < delays.length - 1) {
|
|
1018
1209
|
const wait = delays[attempt];
|
|
1019
1210
|
console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
|
|
1020
|
-
await sleep(wait);
|
|
1211
|
+
await sleep$1(wait);
|
|
1021
1212
|
}
|
|
1022
1213
|
}
|
|
1023
1214
|
result.verified = verified ? "yes" : "pending";
|
|
@@ -1442,7 +1633,8 @@ async function cmdAdsTags(argv) {
|
|
|
1442
1633
|
* with tracking off produces conversions no campaign gets credit for.
|
|
1443
1634
|
*/
|
|
1444
1635
|
async function cmdAdsLint(argv) {
|
|
1445
|
-
const
|
|
1636
|
+
const a = parse$1(argv, SITE_OPTS);
|
|
1637
|
+
const cfg = await siteConfig(a);
|
|
1446
1638
|
const properties = toAdProperties(cfg, void 0);
|
|
1447
1639
|
if (properties.length === 0) {
|
|
1448
1640
|
console.log("· no site.ads properties — nothing to check");
|
|
@@ -1480,6 +1672,922 @@ async function cmdAds(argv) {
|
|
|
1480
1672
|
}
|
|
1481
1673
|
}
|
|
1482
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
|
+
|
|
2175
|
+
//#endregion
|
|
2176
|
+
//#region src/commands/setup.ts
|
|
2177
|
+
/**
|
|
2178
|
+
* `vitops setup` — the scripted path from an empty scaffold to a live site.
|
|
2179
|
+
* Subcommands: `repo | hosting | domain | search | ads | all`.
|
|
2180
|
+
*
|
|
2181
|
+
* Absorbs what each template's `scripts/init-{github,hosting}.mjs` used to do
|
|
2182
|
+
* template-locally, so the capability reaches every consumer, not just a fresh
|
|
2183
|
+
* `vp create` scaffold. `repo`/`hosting` follow the pure-planner/executor split in
|
|
2184
|
+
* `@getvitops/utils/hosting` — see that module's docblock. `search`/`ads` are not
|
|
2185
|
+
* reimplemented: this shells out to the CLI's own `search setup`/`ads setup`
|
|
2186
|
+
* subcommands as child processes, so their behaviour (prompting, `--dry`,
|
|
2187
|
+
* `--check`, writing the config patch back) stays exactly what it already is,
|
|
2188
|
+
* with no risk of the two copies drifting.
|
|
2189
|
+
*
|
|
2190
|
+
* `domain` only ever plans and checks the workers.dev-vs-custom-domain
|
|
2191
|
+
* consistency — it never touches `wrangler.jsonc` or deploys anything. Cutover
|
|
2192
|
+
* overwrites the zone's DNS record and can take an existing live site down; that
|
|
2193
|
+
* is a human's call, made in `SETUP.html`, not a step this command applies.
|
|
2194
|
+
*/
|
|
2195
|
+
const SETUP_HELP = `vitops setup <sub> [options]
|
|
2196
|
+
|
|
2197
|
+
vitops setup repo [--dry] [--check] [--public]
|
|
2198
|
+
vitops setup hosting [--dry] [--check] [--platform cloudflare|node]
|
|
2199
|
+
vitops setup domain [--dry] [--check] [--domain <apex>]
|
|
2200
|
+
vitops setup search [options] (forwards to \`vitops search setup\`)
|
|
2201
|
+
vitops setup ads [options] (forwards to \`vitops ads setup\`)
|
|
2202
|
+
vitops setup all [--dry] [--check] [--public] [--platform <p>]
|
|
2203
|
+
|
|
2204
|
+
--dry prints the plan and stops. --check reports drift/blocked steps and exits
|
|
2205
|
+
non-zero, mutating nothing. Neither creates the Cloudflare API token or performs
|
|
2206
|
+
a custom-domain cutover — both are named as manual steps; see SETUP.html.`;
|
|
2207
|
+
function projectName() {
|
|
2208
|
+
const path = resolve("package.json");
|
|
2209
|
+
if (!existsSync(path)) fail("package.json not found — run this from the project root");
|
|
2210
|
+
const pkg = JSON.parse(readFileSync(path, "utf8"));
|
|
2211
|
+
if (!pkg.name) fail("package.json has no \"name\" field");
|
|
2212
|
+
return pkg.name.replace(/^@[^/]+\//, "");
|
|
2213
|
+
}
|
|
2214
|
+
async function gatherHostingState(name, platform) {
|
|
2215
|
+
const [wrangler, gh, origin] = await Promise.all([
|
|
2216
|
+
platform === "cloudflare" ? wranglerWhoami() : Promise.resolve({ authed: false }),
|
|
2217
|
+
ghAuthed(),
|
|
2218
|
+
originRemote()
|
|
2219
|
+
]);
|
|
2220
|
+
const nwo = origin ? await repoNwo() : void 0;
|
|
2221
|
+
const [branchesList, def, protectedMain, envs, secrets, d1Id, r2] = await Promise.all([
|
|
2222
|
+
branches(),
|
|
2223
|
+
nwo ? defaultBranch(nwo) : Promise.resolve(void 0),
|
|
2224
|
+
nwo ? mainProtected(nwo) : Promise.resolve(false),
|
|
2225
|
+
nwo ? environments(nwo) : Promise.resolve([]),
|
|
2226
|
+
nwo ? secretsPresent(nwo) : Promise.resolve([]),
|
|
2227
|
+
platform === "cloudflare" ? findD1(`${name}-dev`) : Promise.resolve(void 0),
|
|
2228
|
+
platform === "cloudflare" ? findR2Bucket(`${name}-media-dev`) : Promise.resolve(false)
|
|
2229
|
+
]);
|
|
2230
|
+
return {
|
|
2231
|
+
wranglerAuthed: wrangler.authed,
|
|
2232
|
+
..."accountId" in wrangler && wrangler.accountId ? { accountId: wrangler.accountId } : {},
|
|
2233
|
+
d1Present: Boolean(d1Id),
|
|
2234
|
+
...d1Id ? { d1Id } : {},
|
|
2235
|
+
r2Present: r2,
|
|
2236
|
+
ghAuthed: gh,
|
|
2237
|
+
...origin ? { originRemote: origin } : {},
|
|
2238
|
+
...nwo ? { repoNwo: nwo } : {},
|
|
2239
|
+
branches: branchesList,
|
|
2240
|
+
...def ? { defaultBranch: def } : {},
|
|
2241
|
+
mainProtected: protectedMain,
|
|
2242
|
+
environments: envs,
|
|
2243
|
+
secretsPresent: secrets
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
async function cmdSetupRepo(argv) {
|
|
2247
|
+
const a = parse$1(argv, {
|
|
2248
|
+
dry: {
|
|
2249
|
+
type: "boolean",
|
|
2250
|
+
default: false
|
|
2251
|
+
},
|
|
2252
|
+
check: {
|
|
2253
|
+
type: "boolean",
|
|
2254
|
+
default: false
|
|
2255
|
+
},
|
|
2256
|
+
public: {
|
|
2257
|
+
type: "boolean",
|
|
2258
|
+
default: false
|
|
2259
|
+
},
|
|
2260
|
+
platform: {
|
|
2261
|
+
type: "string",
|
|
2262
|
+
default: "cloudflare"
|
|
2263
|
+
},
|
|
2264
|
+
"no-dev-resources": {
|
|
2265
|
+
type: "boolean",
|
|
2266
|
+
default: false
|
|
2267
|
+
}
|
|
2268
|
+
});
|
|
2269
|
+
const platform = a.str("platform");
|
|
2270
|
+
if (platform !== "cloudflare" && platform !== "node") fail(`--platform "${platform}" must be cloudflare or node`);
|
|
2271
|
+
const name = projectName();
|
|
2272
|
+
const setup = {
|
|
2273
|
+
platform,
|
|
2274
|
+
projectName: name,
|
|
2275
|
+
repoVisibility: a.flag("public") ? "public" : "private",
|
|
2276
|
+
devResources: !a.flag("no-dev-resources")
|
|
2277
|
+
};
|
|
2278
|
+
const state = await gatherHostingState(name, setup.platform);
|
|
2279
|
+
const p = plan$4(setup, state);
|
|
2280
|
+
const dry = a.flag("dry");
|
|
2281
|
+
const check = a.flag("check");
|
|
2282
|
+
if (dry || check) {
|
|
2283
|
+
console.log(formatPlan$6(p));
|
|
2284
|
+
if (check && (p.repo.action !== "skip" || p.branches.action !== "skip" || p.defaultBranch.action !== "skip")) {
|
|
2285
|
+
console.error("\n✖ repo/branch setup is not complete");
|
|
2286
|
+
process.exit(1);
|
|
2287
|
+
}
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const results = [];
|
|
2291
|
+
if (p.repo.action === "create") {
|
|
2292
|
+
const r = await createRepo(name, setup.repoVisibility);
|
|
2293
|
+
results.push({
|
|
2294
|
+
step: "repo",
|
|
2295
|
+
status: r.ok ? "created" : "failed",
|
|
2296
|
+
detail: r.ok ? name : r.message ?? "failed"
|
|
2297
|
+
});
|
|
2298
|
+
if (r.ok) {
|
|
2299
|
+
const push = await pushBranches();
|
|
2300
|
+
results.push({
|
|
2301
|
+
step: "branches",
|
|
2302
|
+
status: push.ok ? "created" : "failed",
|
|
2303
|
+
detail: push.ok ? "main + dev pushed" : push.message ?? "failed"
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
} else results.push({
|
|
2307
|
+
step: "repo",
|
|
2308
|
+
status: "present",
|
|
2309
|
+
detail: p.repo.detail
|
|
2310
|
+
});
|
|
2311
|
+
const nwo = state.repoNwo ?? await repoNwo();
|
|
2312
|
+
if (nwo) {
|
|
2313
|
+
if (p.defaultBranch.action !== "skip") {
|
|
2314
|
+
const r = await setDefaultBranch(nwo, "dev");
|
|
2315
|
+
results.push({
|
|
2316
|
+
step: "default branch",
|
|
2317
|
+
status: r.ok ? "updated" : "failed",
|
|
2318
|
+
detail: r.ok ? "dev" : r.message ?? "failed"
|
|
2319
|
+
});
|
|
2320
|
+
} else results.push({
|
|
2321
|
+
step: "default branch",
|
|
2322
|
+
status: "present",
|
|
2323
|
+
detail: "dev"
|
|
2324
|
+
});
|
|
2325
|
+
if (p.mainProtection.action === "blocked") {
|
|
2326
|
+
const r = await protectMain(nwo);
|
|
2327
|
+
results.push({
|
|
2328
|
+
step: "main protection",
|
|
2329
|
+
status: r.ok ? "created" : "blocked",
|
|
2330
|
+
detail: r.ok ? "protected" : r.message ?? p.mainProtection.detail
|
|
2331
|
+
});
|
|
2332
|
+
} else results.push({
|
|
2333
|
+
step: "main protection",
|
|
2334
|
+
status: "present",
|
|
2335
|
+
detail: p.mainProtection.detail
|
|
2336
|
+
});
|
|
2337
|
+
if (p.prodEnvironment.action === "blocked") {
|
|
2338
|
+
const ids = await adminCollaboratorIds(nwo);
|
|
2339
|
+
const r = await createProdEnvironment(nwo, ids);
|
|
2340
|
+
results.push({
|
|
2341
|
+
step: "production env",
|
|
2342
|
+
status: r.ok ? "created" : "blocked",
|
|
2343
|
+
detail: r.ok ? `${ids.length} reviewer(s)` : r.message ?? p.prodEnvironment.detail
|
|
2344
|
+
});
|
|
2345
|
+
} else results.push({
|
|
2346
|
+
step: "production env",
|
|
2347
|
+
status: "present",
|
|
2348
|
+
detail: p.prodEnvironment.detail
|
|
2349
|
+
});
|
|
2350
|
+
if (p.devEnvironment.action === "create") {
|
|
2351
|
+
const r = await createDevEnvironment(nwo);
|
|
2352
|
+
results.push({
|
|
2353
|
+
step: "dev env",
|
|
2354
|
+
status: r.ok ? "created" : "failed",
|
|
2355
|
+
detail: r.ok ? "no required reviewers" : r.message ?? "failed"
|
|
2356
|
+
});
|
|
2357
|
+
} else results.push({
|
|
2358
|
+
step: "dev env",
|
|
2359
|
+
status: "present",
|
|
2360
|
+
detail: p.devEnvironment.detail
|
|
2361
|
+
});
|
|
2362
|
+
} else results.push({
|
|
2363
|
+
step: "branch policy",
|
|
2364
|
+
status: "blocked",
|
|
2365
|
+
detail: "no GitHub repo yet — run this again once \"repo\" succeeds"
|
|
2366
|
+
});
|
|
2367
|
+
console.log(formatSummary$3(results));
|
|
2368
|
+
if (results.some((r) => r.status === "failed")) process.exit(1);
|
|
2369
|
+
}
|
|
2370
|
+
async function cmdSetupHosting(argv) {
|
|
2371
|
+
const a = parse$1(argv, {
|
|
2372
|
+
dry: {
|
|
2373
|
+
type: "boolean",
|
|
2374
|
+
default: false
|
|
2375
|
+
},
|
|
2376
|
+
check: {
|
|
2377
|
+
type: "boolean",
|
|
2378
|
+
default: false
|
|
2379
|
+
},
|
|
2380
|
+
platform: {
|
|
2381
|
+
type: "string",
|
|
2382
|
+
default: "cloudflare"
|
|
2383
|
+
},
|
|
2384
|
+
"no-dev-resources": {
|
|
2385
|
+
type: "boolean",
|
|
2386
|
+
default: false
|
|
2387
|
+
}
|
|
2388
|
+
});
|
|
2389
|
+
const platform = a.str("platform");
|
|
2390
|
+
if (platform !== "cloudflare" && platform !== "node") fail(`--platform "${platform}" must be cloudflare or node`);
|
|
2391
|
+
const name = projectName();
|
|
2392
|
+
const setup = {
|
|
2393
|
+
platform,
|
|
2394
|
+
projectName: name,
|
|
2395
|
+
repoVisibility: "private",
|
|
2396
|
+
devResources: !a.flag("no-dev-resources")
|
|
2397
|
+
};
|
|
2398
|
+
const state = await gatherHostingState(name, platform);
|
|
2399
|
+
const p = plan$4(setup, state);
|
|
2400
|
+
const dry = a.flag("dry");
|
|
2401
|
+
const check = a.flag("check");
|
|
2402
|
+
if (platform === "node") {
|
|
2403
|
+
console.log("Hosting target \"node\" has no cloud resources to provision — see the template README's \"Hosting\" section.");
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
if (!state.wranglerAuthed && !dry) fail("wrangler is not logged in — run `npx wrangler login` first");
|
|
2407
|
+
if (dry || check) {
|
|
2408
|
+
console.log(formatPlan$6(p));
|
|
2409
|
+
if (check && (p.d1.action !== "skip" || p.r2.action !== "skip")) {
|
|
2410
|
+
console.error("\n✖ hosting resources are not fully provisioned");
|
|
2411
|
+
process.exit(1);
|
|
2412
|
+
}
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
const results = [];
|
|
2416
|
+
let d1Id = state.d1Id;
|
|
2417
|
+
if (p.d1.action === "create") {
|
|
2418
|
+
const r = await createD1(`${name}-dev`);
|
|
2419
|
+
results.push({
|
|
2420
|
+
step: "dev D1",
|
|
2421
|
+
status: r.ok ? "created" : "failed",
|
|
2422
|
+
detail: r.ok ? r.id ?? "" : r.message ?? "failed"
|
|
2423
|
+
});
|
|
2424
|
+
if (r.ok) d1Id = r.id;
|
|
2425
|
+
} else results.push({
|
|
2426
|
+
step: "dev D1",
|
|
2427
|
+
status: "present",
|
|
2428
|
+
detail: p.d1.detail
|
|
2429
|
+
});
|
|
2430
|
+
if (p.r2.action === "create") {
|
|
2431
|
+
const r = await createR2Bucket(`${name}-media-dev`);
|
|
2432
|
+
results.push({
|
|
2433
|
+
step: "dev R2",
|
|
2434
|
+
status: r.ok ? "created" : "failed",
|
|
2435
|
+
detail: r.ok ? `${name}-media-dev` : r.message ?? "failed"
|
|
2436
|
+
});
|
|
2437
|
+
} else results.push({
|
|
2438
|
+
step: "dev R2",
|
|
2439
|
+
status: "present",
|
|
2440
|
+
detail: p.r2.detail
|
|
2441
|
+
});
|
|
2442
|
+
if ((state.repoNwo ?? await repoNwo()) && state.ghAuthed) {
|
|
2443
|
+
if (d1Id && !state.secretsPresent.includes("DEV_D1_ID")) {
|
|
2444
|
+
const r = await setSecret("DEV_D1_ID", d1Id);
|
|
2445
|
+
results.push({
|
|
2446
|
+
step: "secret DEV_D1_ID",
|
|
2447
|
+
status: r.ok ? "created" : "failed",
|
|
2448
|
+
detail: r.ok ? "set" : r.message ?? "failed"
|
|
2449
|
+
});
|
|
2450
|
+
} else results.push({
|
|
2451
|
+
step: "secret DEV_D1_ID",
|
|
2452
|
+
status: state.secretsPresent.includes("DEV_D1_ID") ? "present" : "blocked",
|
|
2453
|
+
detail: d1Id ?? "D1 id unknown"
|
|
2454
|
+
});
|
|
2455
|
+
if (state.accountId && !state.secretsPresent.includes("CLOUDFLARE_ACCOUNT_ID")) {
|
|
2456
|
+
const r = await setSecret("CLOUDFLARE_ACCOUNT_ID", state.accountId);
|
|
2457
|
+
results.push({
|
|
2458
|
+
step: "secret CLOUDFLARE_ACCOUNT_ID",
|
|
2459
|
+
status: r.ok ? "created" : "failed",
|
|
2460
|
+
detail: r.ok ? "set" : r.message ?? "failed"
|
|
2461
|
+
});
|
|
2462
|
+
} else results.push({
|
|
2463
|
+
step: "secret CLOUDFLARE_ACCOUNT_ID",
|
|
2464
|
+
status: state.secretsPresent.includes("CLOUDFLARE_ACCOUNT_ID") ? "present" : "blocked",
|
|
2465
|
+
detail: state.accountId ?? "unknown"
|
|
2466
|
+
});
|
|
2467
|
+
} else results.push({
|
|
2468
|
+
step: "secrets",
|
|
2469
|
+
status: "blocked",
|
|
2470
|
+
detail: "no GitHub repo wired up yet — run `vitops setup repo` first"
|
|
2471
|
+
});
|
|
2472
|
+
results.push({
|
|
2473
|
+
step: "secret CLOUDFLARE_API_TOKEN",
|
|
2474
|
+
status: "blocked",
|
|
2475
|
+
detail: p.secrets["CLOUDFLARE_API_TOKEN"]?.detail ?? "create it by hand"
|
|
2476
|
+
});
|
|
2477
|
+
console.log(formatSummary$3(results));
|
|
2478
|
+
for (const n of p.notes) console.log(`! ${n}`);
|
|
2479
|
+
if (results.some((r) => r.status === "failed")) process.exit(1);
|
|
2480
|
+
}
|
|
2481
|
+
async function cmdSetupDomain(argv) {
|
|
2482
|
+
const a = parse$1(argv, {
|
|
2483
|
+
...SITE_OPTS,
|
|
2484
|
+
dry: {
|
|
2485
|
+
type: "boolean",
|
|
2486
|
+
default: false
|
|
2487
|
+
},
|
|
2488
|
+
check: {
|
|
2489
|
+
type: "boolean",
|
|
2490
|
+
default: false
|
|
2491
|
+
},
|
|
2492
|
+
domain: { type: "string" }
|
|
2493
|
+
});
|
|
2494
|
+
const wranglerPath = resolve("wrangler.jsonc");
|
|
2495
|
+
if (!existsSync(wranglerPath)) fail("wrangler.jsonc not found — run this from the project root");
|
|
2496
|
+
const raw = readFileSync(wranglerPath, "utf8");
|
|
2497
|
+
const customDomainMatch = /^\s*"routes":\s*\[\s*\{\s*"pattern":\s*"([^"]+)"/m.exec(raw.split("\n").filter((l) => !l.trim().startsWith("//")).join("\n"));
|
|
2498
|
+
const customDomain = customDomainMatch?.[1] ?? (a.opt("domain") ? void 0 : void 0);
|
|
2499
|
+
const cfg = await loadConfig(a.path("input"), a.str("site-env"));
|
|
2500
|
+
const env = cfg.site.environments[a.str("site-env")] ?? cfg.site.environments["production"];
|
|
2501
|
+
const state = {
|
|
2502
|
+
wranglerDev: !raw.includes("\"routes\"") || Boolean(customDomainMatch) === false,
|
|
2503
|
+
...customDomain ? { customDomain } : {},
|
|
2504
|
+
...env?.url ? { configuredUrl: env.url } : {},
|
|
2505
|
+
...cfg.site.domains?.canonical ? { canonical: cfg.site.domains.canonical } : {},
|
|
2506
|
+
...env?.robots ? { robots: env.robots } : {}
|
|
2507
|
+
};
|
|
2508
|
+
const step = planDomainConsistency(state);
|
|
2509
|
+
console.log(`${step.action === "skip" ? "·" : "?"} domain consistency ${step.detail}`);
|
|
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.`);
|
|
2511
|
+
if (a.flag("check") && step.action !== "skip") process.exit(1);
|
|
2512
|
+
}
|
|
2513
|
+
/** Forward to the CLI's own `search setup` / `ads setup` as a child process — see
|
|
2514
|
+
* this module's docblock for why (their behaviour must not be re-derived). */
|
|
2515
|
+
function forward(sub, argv) {
|
|
2516
|
+
try {
|
|
2517
|
+
execFileSync(process.execPath, [
|
|
2518
|
+
process.argv[1],
|
|
2519
|
+
sub,
|
|
2520
|
+
"setup",
|
|
2521
|
+
...argv
|
|
2522
|
+
], { stdio: "inherit" });
|
|
2523
|
+
return true;
|
|
2524
|
+
} catch (err) {
|
|
2525
|
+
if (err.status != null) return false;
|
|
2526
|
+
fail(msg(err));
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
async function cmdSetup(argv) {
|
|
2530
|
+
const [sub, ...rest] = argv;
|
|
2531
|
+
switch (sub) {
|
|
2532
|
+
case "repo": return cmdSetupRepo(rest);
|
|
2533
|
+
case "hosting": return cmdSetupHosting(rest);
|
|
2534
|
+
case "domain": return cmdSetupDomain(rest);
|
|
2535
|
+
case "search":
|
|
2536
|
+
if (!forward("search", rest)) process.exit(1);
|
|
2537
|
+
return;
|
|
2538
|
+
case "ads":
|
|
2539
|
+
if (!forward("ads", rest)) process.exit(1);
|
|
2540
|
+
return;
|
|
2541
|
+
case "all": {
|
|
2542
|
+
const a = parse$1(rest, {
|
|
2543
|
+
dry: {
|
|
2544
|
+
type: "boolean",
|
|
2545
|
+
default: false
|
|
2546
|
+
},
|
|
2547
|
+
check: {
|
|
2548
|
+
type: "boolean",
|
|
2549
|
+
default: false
|
|
2550
|
+
},
|
|
2551
|
+
public: {
|
|
2552
|
+
type: "boolean",
|
|
2553
|
+
default: false
|
|
2554
|
+
},
|
|
2555
|
+
platform: {
|
|
2556
|
+
type: "string",
|
|
2557
|
+
default: "cloudflare"
|
|
2558
|
+
},
|
|
2559
|
+
"no-dev-resources": {
|
|
2560
|
+
type: "boolean",
|
|
2561
|
+
default: false
|
|
2562
|
+
}
|
|
2563
|
+
});
|
|
2564
|
+
const dryCheckFlags = [...a.flag("dry") ? ["--dry"] : [], ...a.flag("check") ? ["--check"] : []];
|
|
2565
|
+
const hostingFlags = [
|
|
2566
|
+
...dryCheckFlags,
|
|
2567
|
+
"--platform",
|
|
2568
|
+
a.str("platform"),
|
|
2569
|
+
...a.flag("no-dev-resources") ? ["--no-dev-resources"] : []
|
|
2570
|
+
];
|
|
2571
|
+
console.log("── repo ──");
|
|
2572
|
+
await cmdSetupRepo([...hostingFlags, ...a.flag("public") ? ["--public"] : []]);
|
|
2573
|
+
console.log("\n── hosting ──");
|
|
2574
|
+
await cmdSetupHosting(hostingFlags);
|
|
2575
|
+
console.log("\n── search ──");
|
|
2576
|
+
const searchOk = forward("search", dryCheckFlags);
|
|
2577
|
+
console.log("\n── ads ──");
|
|
2578
|
+
const adsOk = forward("ads", dryCheckFlags);
|
|
2579
|
+
if (!searchOk || !adsOk) process.exit(1);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
case void 0:
|
|
2583
|
+
case "-h":
|
|
2584
|
+
case "--help":
|
|
2585
|
+
console.log(SETUP_HELP);
|
|
2586
|
+
return;
|
|
2587
|
+
default: fail(`unknown "setup" subcommand "${sub}" (expected: repo | hosting | domain | search | ads | all). Try: vitops --help`);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
1483
2591
|
//#endregion
|
|
1484
2592
|
//#region src/agents.ts
|
|
1485
2593
|
/**
|
|
@@ -1951,7 +3059,7 @@ function lintCss(files, format) {
|
|
|
1951
3059
|
* A consumer's markdown content can hold real markup, so this does give up a
|
|
1952
3060
|
* little coverage; that is the right side to err on for a heuristic.
|
|
1953
3061
|
*/
|
|
1954
|
-
const MARKUP_EXT = new Set([
|
|
3062
|
+
const MARKUP_EXT = /* @__PURE__ */ new Set([
|
|
1955
3063
|
".astro",
|
|
1956
3064
|
".html",
|
|
1957
3065
|
".htm",
|
|
@@ -2397,6 +3505,8 @@ function lintSource(files, v, format) {
|
|
|
2397
3505
|
* vitops reviews [--input <site.json>] [--location <key>] [--dry] [--check] [--out <dir>]
|
|
2398
3506
|
* vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
|
|
2399
3507
|
* vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
|
|
3508
|
+
* vitops setup repo | hosting | domain | search | ads | all [--dry] [--check]
|
|
3509
|
+
* vitops email setup [domain] [--dry] [--check] | check [--strict] [--skip]
|
|
2400
3510
|
* vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
|
|
2401
3511
|
*
|
|
2402
3512
|
* Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
|
|
@@ -2406,23 +3516,23 @@ function lintSource(files, v, format) {
|
|
|
2406
3516
|
* command groups big enough, and self-contained enough, to earn their own file.
|
|
2407
3517
|
* Everything else stays here.
|
|
2408
3518
|
*/
|
|
2409
|
-
const FORMATS = new Set([
|
|
3519
|
+
const FORMATS = /* @__PURE__ */ new Set([
|
|
2410
3520
|
"bricks",
|
|
2411
3521
|
"css",
|
|
2412
3522
|
"tailwind",
|
|
2413
3523
|
"design"
|
|
2414
3524
|
]);
|
|
2415
|
-
const LINT_FORMATS = new Set([
|
|
3525
|
+
const LINT_FORMATS = /* @__PURE__ */ new Set([
|
|
2416
3526
|
"bricks",
|
|
2417
3527
|
"css",
|
|
2418
3528
|
"tailwind"
|
|
2419
3529
|
]);
|
|
2420
|
-
const LEGAL_DOCS = new Set([
|
|
3530
|
+
const LEGAL_DOCS = /* @__PURE__ */ new Set([
|
|
2421
3531
|
"privacy",
|
|
2422
3532
|
"terms",
|
|
2423
3533
|
"cookies"
|
|
2424
3534
|
]);
|
|
2425
|
-
const LEGAL_OUTPUTS = new Set([
|
|
3535
|
+
const LEGAL_OUTPUTS = /* @__PURE__ */ new Set([
|
|
2426
3536
|
"md",
|
|
2427
3537
|
"html",
|
|
2428
3538
|
"portable-text"
|
|
@@ -2611,7 +3721,7 @@ async function cmdFavicon(argv) {
|
|
|
2611
3721
|
fail(msg(err));
|
|
2612
3722
|
}
|
|
2613
3723
|
}
|
|
2614
|
-
const OUTPUT_KINDS = new Set([
|
|
3724
|
+
const OUTPUT_KINDS = /* @__PURE__ */ new Set([
|
|
2615
3725
|
"webm",
|
|
2616
3726
|
"mp4",
|
|
2617
3727
|
"poster"
|
|
@@ -2908,7 +4018,8 @@ async function cmdRobots(argv) {
|
|
|
2908
4018
|
}
|
|
2909
4019
|
});
|
|
2910
4020
|
const siteEnv = a.str("site-env");
|
|
2911
|
-
const
|
|
4021
|
+
const site = await loadConfig(a.path("input"), siteEnv);
|
|
4022
|
+
const content = renderRobotsTxt(site, { env: siteEnv });
|
|
2912
4023
|
if (!a.opt("out")) {
|
|
2913
4024
|
process.stdout.write(content);
|
|
2914
4025
|
return;
|
|
@@ -3040,7 +4151,8 @@ function cliVersion() {
|
|
|
3040
4151
|
}
|
|
3041
4152
|
}
|
|
3042
4153
|
function generatorAssetsDir() {
|
|
3043
|
-
|
|
4154
|
+
const pkg = createRequire(import.meta.url).resolve("@getvitops/generator/package.json");
|
|
4155
|
+
return join(dirname(pkg), "assets");
|
|
3044
4156
|
}
|
|
3045
4157
|
const AGENTS_START = "<!-- vitops:start -->";
|
|
3046
4158
|
const AGENTS_END = "<!-- vitops:end -->";
|
|
@@ -3069,7 +4181,8 @@ function cmdDocs(argv) {
|
|
|
3069
4181
|
return;
|
|
3070
4182
|
}
|
|
3071
4183
|
if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
|
|
3072
|
-
const
|
|
4184
|
+
const ds = loadDesignSystem(resolve(values.input), values.theme);
|
|
4185
|
+
const docs = generateDocs(ds, generatorAssetsDir());
|
|
3073
4186
|
const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
|
|
3074
4187
|
process.stdout.write(paths.map((p) => docs[p]).join("\n"));
|
|
3075
4188
|
}
|
|
@@ -3116,7 +4229,7 @@ function cmdLint(argv) {
|
|
|
3116
4229
|
"icon",
|
|
3117
4230
|
"border"
|
|
3118
4231
|
]).map((u) => u.cls);
|
|
3119
|
-
const LINTABLE = new Set([
|
|
4232
|
+
const LINTABLE = /* @__PURE__ */ new Set([
|
|
3120
4233
|
".astro",
|
|
3121
4234
|
".html",
|
|
3122
4235
|
".js",
|
|
@@ -3349,6 +4462,9 @@ async function main() {
|
|
|
3349
4462
|
case "icons": return cmdIcons(rest);
|
|
3350
4463
|
case "search": return cmdSearch(rest);
|
|
3351
4464
|
case "ads": return cmdAds(rest);
|
|
4465
|
+
case "crm": return cmdCrm(rest);
|
|
4466
|
+
case "email": return cmdEmail(rest);
|
|
4467
|
+
case "setup": return cmdSetup(rest);
|
|
3352
4468
|
case "media": return cmdMedia(rest);
|
|
3353
4469
|
default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
|
|
3354
4470
|
}
|