@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/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
- return pickOne(Object.entries(entries).map(([domain, e]) => ({
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
- })), domainFilter, (s) => s.domain, {
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) if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
874
- else {
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
- console.error(`✖ Search Console: ${r.message}`);
879
- failed = true;
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 cfg = await siteConfig(parse$1(argv, SITE_OPTS));
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 content = renderRobotsTxt(await loadConfig(a.path("input"), siteEnv), { env: siteEnv });
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
- return join(dirname(createRequire(import.meta.url).resolve("@getvitops/generator/package.json")), "assets");
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 docs = generateDocs(loadDesignSystem(resolve(values.input), values.theme), generatorAssetsDir());
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
  }