@getvitops/cli 8.1.0 → 8.2.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
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
+ import { pathToFileURL } from "node:url";
4
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
6
  import { dirname, extname, join, parse, relative, resolve } from "node:path";
6
7
  import { CONFIG_SCHEMA_LOCAL_PATH, JURISDICTION_COUNTRIES, NUMERIC_STEPS, SCHEMA_LOCAL_PATH, buildIconSprite, defaultConfig, defaultSiteConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, movedTokens, processorsMissingLocation, renderRobotsTxt, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
@@ -9,15 +10,17 @@ import { SCOPES, SNAPSHOT_PATH, adcCredentialsPath, adcQuotaProject, collectEntr
9
10
  import { REVIEWS_CACHE_PATH, fetchReviews, formatPlan as formatPlan$1, mergeCache, plan as plan$1, readCache, selectReviews, toCache, writeCache } from "@getvitops/utils/reviews";
10
11
  import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$2, processMedia } from "@getvitops/utils/media";
11
12
  import { homedir } from "node:os";
12
- import { pathToFileURL } from "node:url";
13
13
  import { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan as formatPlan$3, formatSummary, getSite, getVerificationToken, getWebResource, hasDrift, listApexTxt, ownerUnion, plan as plan$2, refreshTokenGrant, siteUrlFor, updateOwners, verifyWebResource } from "@getvitops/utils/onboarding";
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";
14
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS, formatPlan as formatPlan$4, formatSummary as formatSummary$1, hasDrift as hasDrift$1, isAdProvider, missingFields, normalizeConversionActionIds, normalizeConversionLabels, plan as plan$3, renderTag, tagId } from "@getvitops/utils/ads";
15
15
  import { PLATFORM_PARAMS } from "@getvitops/utils/tracking";
16
16
  import { createInterface } from "node:readline/promises";
17
+ import { zohoAccountsHost, zohoApiHost } from "@getvitops/utils/crm";
18
+ import { backoffSchedule as backoffSchedule$1, blockedDomains, collectFromAddresses, dnsStatus, enableSending, findZoneForHost, formatPlan as formatPlan$5, formatSummary as formatSummary$2, hasDrift as hasDrift$2, listSendingSubdomains, planSendingDomains, resolveTxt, sendingDns } from "@getvitops/utils/email";
19
+ import { normalizeEmailChannel, resolveSender } from "@getvitops/utils/notify";
17
20
  import { execFileSync } from "node:child_process";
18
- import { adminCollaboratorIds, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan as formatPlan$5, formatSummary as formatSummary$2, ghAuthed, mainProtected, originRemote, plan as plan$4, planDomainConsistency, protectMain, pushBranches, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami } from "@getvitops/utils/hosting";
21
+ import { adminCollaboratorIds, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan as formatPlan$6, formatSummary as formatSummary$3, ghAuthed, mainProtected, originRemote, plan as plan$4, planDomainConsistency, protectMain, pushBranches, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami } from "@getvitops/utils/hosting";
19
22
 
20
- //#region src/args.ts
23
+ // src/args.ts
21
24
  /**
22
25
  * A typed wrapper around `node:util`'s `parseArgs`, used by every `vitops` subcommand.
23
26
  *
@@ -56,8 +59,7 @@ function msg(err) {
56
59
  return err.message;
57
60
  }
58
61
 
59
- //#endregion
60
- //#region src/commands/shared.ts
62
+ // src/commands/shared.ts
61
63
  /**
62
64
  * Helpers shared by the `vitops` subcommands that read a `Config` (site.json) —
63
65
  * `legal`, `robots`, `reviews`, `icons`, and the `search`/`ads` subcommand groups
@@ -155,8 +157,7 @@ function writeOut(path, contents) {
155
157
  writeFileSync(path, contents);
156
158
  }
157
159
 
158
- //#endregion
159
- //#region src/credentials.ts
160
+ // src/credentials.ts
160
161
  /**
161
162
  * Credential loaders shared by `search setup`, `search notify`, `ads setup` and
162
163
  * `reviews` — every command that talks to Google or Cloudflare. Never reads a
@@ -296,9 +297,37 @@ function loadSearchCredential() {
296
297
  }
297
298
  /** Both spellings, for an error that has to tell you what to actually set. */
298
299
  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";
300
+ /**
301
+ * The HubSpot private-app access token, from the environment. Never the
302
+ * config file — same rule as every other credential here.
303
+ */
304
+ function loadHubSpotToken() {
305
+ return process.env.VITOPS_HUBSPOT_ACCESS_TOKEN || void 0;
306
+ }
307
+ /**
308
+ * The Zoho self-client OAuth credential, for Bigin. All-or-nothing, matching
309
+ * `loadGoogleOAuth`'s own rule: a partial set is a misconfiguration worth
310
+ * naming, not a reason to run degraded.
311
+ */
312
+ function loadZohoOAuth() {
313
+ const clientId = process.env.VITOPS_ZOHO_CLIENT_ID;
314
+ const clientSecret = process.env.VITOPS_ZOHO_CLIENT_SECRET;
315
+ const refreshToken = process.env.VITOPS_ZOHO_REFRESH_TOKEN;
316
+ if (!clientId && !clientSecret && !refreshToken) return void 0;
317
+ const missing = [
318
+ ["VITOPS_ZOHO_CLIENT_ID", clientId],
319
+ ["VITOPS_ZOHO_CLIENT_SECRET", clientSecret],
320
+ ["VITOPS_ZOHO_REFRESH_TOKEN", refreshToken]
321
+ ].filter(([, v]) => !v).map(([k]) => k);
322
+ if (missing.length) fail(`incomplete Zoho OAuth credential — missing ${missing.join(", ")}`);
323
+ return {
324
+ clientId,
325
+ clientSecret,
326
+ refreshToken
327
+ };
328
+ }
299
329
 
300
- //#endregion
301
- //#region src/help.ts
330
+ // src/help.ts
302
331
  /**
303
332
  * The CLI's help text, and the addressing that lets every subcommand answer
304
333
  * `--help`.
@@ -329,6 +358,7 @@ Usage:
329
358
  vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
330
359
  vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
331
360
  vitops setup <sub> [opts] Scripted onboarding: repo, hosting, domain, search, ads, all
361
+ vitops email <sub> [opts] Sender-domain onboarding: check (setup) + deploy guard (check)
332
362
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
333
363
 
334
364
  Generate options:
@@ -577,6 +607,33 @@ Ads lint options:
577
607
  off (every conversion arrives unattributed), and a configured property with no
578
608
  tag ID. Exits non-zero on a finding.
579
609
 
610
+ Crm check options:
611
+ -i, --input <path> Site config carrying site.crm (default: ./site.json)
612
+ --site-env <env> Environment to resolve (default: production)
613
+
614
+ Fetches the live field/property list (HubSpot: crm/v3/properties/contacts;
615
+ Bigin: bigin/v2/settings/fields) and reports every fieldMap/trackingFields
616
+ target that doesn't exist there. HubSpot rejects a submission naming an
617
+ unrecognised property; Bigin silently ignores it — this is the only way to
618
+ see a broken mapping before a real submission finds it. Needs
619
+ VITOPS_HUBSPOT_ACCESS_TOKEN, VITOPS_ZOHO_CLIENT_ID/_CLIENT_SECRET/
620
+ _REFRESH_TOKEN, or (unused for now) VITOPS_JOBBER_CLIENT_ID/_CLIENT_SECRET/
621
+ _REFRESH_TOKEN, matching whichever provider site.crm names. Jobber currently
622
+ renders fieldMap/trackingFields as note-text lines rather than app-configured
623
+ custom fields, so there is nothing live to check there yet. Exits non-zero
624
+ on any missing target.
625
+
626
+ Crm setup options:
627
+ -i, --input <path> Site config carrying site.crm (default: ./site.json)
628
+ --site-env <env> Environment to resolve (default: production)
629
+ --dry Print what would be created and exit. Creates nothing.
630
+
631
+ HubSpot: creates missing contact properties named by fieldMap/trackingFields.
632
+ Bigin: no such API exists, so this always prints a manual reminder listing
633
+ the fields to add in Zoho Bigin's field editor, then re-run "crm check".
634
+ Jobber: app-configured custom fields aren't wired up yet, so this prints a
635
+ reminder instead of creating anything.
636
+
580
637
  Setup repo options:
581
638
  --dry Print the plan and exit
582
639
  --check Report drift/blocked steps and exit non-zero, mutating nothing
@@ -632,6 +689,46 @@ Setup all options:
632
689
  Runs repo, hosting, search, ads in order. Stops at the first stage that fails;
633
690
  a blocked step (the API token, GitHub Pro/Team gating) is reported, not fatal.
634
691
 
692
+ Email subcommands:
693
+ vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
694
+ vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
695
+
696
+ Email setup options:
697
+ -i, --input <path> Site config carrying site.notifications.email (default: ./site.json)
698
+ --site-env <env> Environment whose A/B variant applies (default: production)
699
+ [domain] Onboard this exact name instead of deriving candidates from
700
+ config + a source scan
701
+ --src <dir> Source to scan for from: literals as a supplement to config
702
+ (default: ./src)
703
+ --dry Print the plan and exit. Needs no credential — plans from
704
+ scratch and says so.
705
+ --check Report drift and exit non-zero. Mutates nothing.
706
+ --no-prompt Never ask before enabling a domain (the default when
707
+ stdin is not a TTY, e.g. in CI — it just enables)
708
+ --timeout <s> Cap on each DNS-propagation poll wait, in seconds (default: 5)
709
+
710
+ The onboarded entity is an EXACT name, and it is usually a subdomain
711
+ (send.example.com), never the apex a from address names. When the exact
712
+ domain is absent but a sibling in the same zone is already onboarded, this
713
+ is reported blocked rather than silently enabling the apex too — enabling
714
+ it rewrites _dmarc.<domain>, which can tighten policy on every OTHER mail
715
+ sent from that domain. Sending from the onboarded sibling is the
716
+ recommended fix. Needs CLOUDFLARE_API_TOKEN (Zone:Read + an Email Sending
717
+ scope — broader than the "Edit zone DNS" template other commands use).
718
+
719
+ Email check options:
720
+ -i, --input <path> Site config carrying site.notifications.email (default: ./site.json)
721
+ --site-env <env> Environment whose A/B variant applies (default: production)
722
+ --src <dir> Source to scan for from: literals (default: ./src)
723
+ --strict Exit non-zero on drift (the default just warns and exits 0)
724
+ --skip Skip the check entirely (same as VITOPS_SKIP_EMAIL_CHECK)
725
+
726
+ Meant to run before a deploy. With no CLOUDFLARE_API_TOKEN it reports "not
727
+ checked" and exits 0, so a CI job without the secret never fails on the
728
+ check itself. Skip per-site with site.notifications.email.verifySender: false
729
+ when the scan can't see the real from address and the warning would be a
730
+ false positive.
731
+
635
732
  Media options:
636
733
  --raw <dir> Directory of unprocessed video, walked recursively
637
734
  (default: ./raw)
@@ -673,6 +770,20 @@ const ADS_HELP = `vitops ads — link this site to its ad properties
673
770
  vitops ads tags [opts] Print the consent-gated pixel snippets
674
771
  vitops ads lint [opts] Report ad properties the rest of the config can't see
675
772
 
773
+ Run \`vitops --help\` for the full option list for each subcommand.
774
+ `;
775
+ const CRM_HELP = `vitops crm — push form submissions into a client CRM
776
+
777
+ vitops crm check [opts] Compare site.crm's fieldMap/trackingFields against the live CRM
778
+ vitops crm setup [opts] Create missing HubSpot properties (Bigin/Jobber have no such API)
779
+
780
+ Run \`vitops --help\` for the full option list for each subcommand.
781
+ `;
782
+ const EMAIL_HELP = `vitops email — sender-domain onboarding checks
783
+
784
+ vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
785
+ vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
786
+
676
787
  Run \`vitops --help\` for the full option list for each subcommand.
677
788
  `;
678
789
  const SETUP_HELP$1 = `vitops setup — the scripted path from an empty scaffold to a live site
@@ -706,7 +817,9 @@ const COMMANDS = [
706
817
  "icons",
707
818
  "search",
708
819
  "ads",
820
+ "crm",
709
821
  "setup",
822
+ "email",
710
823
  "media"
711
824
  ];
712
825
  /**
@@ -736,11 +849,10 @@ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
736
849
  /** The help text to print for `vitops <command> [sub] --help`. */
737
850
  function helpFor(command, argv) {
738
851
  const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
739
- return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? SEARCH_HELP : command === "ads" ? ADS_HELP : command === "setup" ? SETUP_HELP$1 : void 0) ?? HELP;
852
+ return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? "vitops search — Google Search Console\n\n vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties\n vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)\n\nRun `vitops --help` for the full option list for each subcommand.\n" : command === "ads" ? "vitops ads — link this site to its ad properties\n\n vitops ads setup [opts] Ensure each platform's domain-verification DNS record\n vitops ads tags [opts] Print the consent-gated pixel snippets\n vitops ads lint [opts] Report ad properties the rest of the config can't see\n\nRun `vitops --help` for the full option list for each subcommand.\n" : command === "crm" ? "vitops crm — push form submissions into a client CRM\n\n vitops crm check [opts] Compare site.crm's fieldMap/trackingFields against the live CRM\n vitops crm setup [opts] Create missing HubSpot properties (Bigin/Jobber have no such API)\n\nRun `vitops --help` for the full option list for each subcommand.\n" : command === "setup" ? "vitops setup — the scripted path from an empty scaffold to a live site\n\n vitops setup repo [opts] GitHub repo + branch policy (main protected, dev open)\n vitops setup hosting [opts] Dev D1 + R2 + repo secrets (cloudflare) or a Node target\n vitops setup domain [opts] Plan/check the workers.dev-vs-custom-domain consistency\n (never applies the cutover — see SETUP.html)\n vitops setup search [opts] Forwards to `vitops search setup`\n vitops setup ads [opts] Forwards to `vitops ads setup`\n vitops setup all [opts] Run repo, hosting, search, ads in order\n\nRun `vitops --help` for the full option list for each subcommand.\n" : command === "email" ? "vitops email — sender-domain onboarding checks\n\n vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve\n vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded\n\nRun `vitops --help` for the full option list for each subcommand.\n" : void 0) ?? "vitops — generate design-system outputs from a design-system.json\n\nAnywhere a command takes --input, that file may be a design-system.json OR the\nlarger site config that embeds one (company.json / site.json). The two are told\napart by shape, and a site config also supplies the site-level facts generation\nreads — its default colour scheme, its legal documents, its icon sprite.\n\nUsage:\n vitops generate [options] Generate platform output from a config\n vitops init [options] Scaffold a starter design-system.json\n vitops validate <file> Validate a config against the schema it says it is\n vitops favicon [options] Generate a favicon set from a source image\n vitops agents [options] Link the design-system agent skill + AGENTS.md pointer\n vitops docs [topic] Print live design-system reference docs to stdout\n vitops lint [opts] [files] Report classes that resolve to nothing + primitives you re-implemented\n vitops legal [options] Render legal documents from a site config\n vitops robots [options] Render robots.txt from a site config's environment\n vitops reviews [options] Fetch + cache Google Business Profile reviews\n vitops icons [options] Report which icons your source uses, and build the sprite\n vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)\n vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint\n vitops setup <sub> [opts] Scripted onboarding: repo, hosting, domain, search, ads, all\n vitops email <sub> [opts] Sender-domain onboarding: check (setup) + deploy guard (check)\n vitops media [options] Encode raw video into web-ready WebM + MP4 + poster\n\nGenerate options:\n -i, --input <path> design-system.json or site config (default: ./design-system.json)\n -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)\n design emits DESIGN.md only (the agent-facing brief) — pair it\n with --out . to write it beside AGENTS.md, or compose:\n --format css,design\n -o, --out <dir> Output directory (default: ./dist)\n --theme <name> Which designSystem.themes entry to build, when --input is\n a site config (default: its defaultTheme, else \"default\")\n --site <path> Site config, when it is a different file from --input.\n Emits legal/*.html into the output directory (see:\n vitops legal). Omitted with a plain design-system.json:\n no legal files.\n --site-env <env> Environment whose A/B variant applies (default: production)\n\nInit options:\n -o, --out <path> Where to write (default: ./design-system.json, or\n ./site.json with --site)\n --force Overwrite an existing file\n --site Write a site config (Config) instead of a bare\n design-system.json — embeds a starter design system at\n designSystem.themes.default, since a Config can't\n merely reference a sibling file\n --name <name> organization.name, when --site is set\n --url <url> site.environments.{production,dev}.url, when --site is set\n\nValidate options:\n <file> Config to check (default: ./design-system.json). Either\n kind — a design-system.json or the config that embeds one.\n -i, --input <path> Same, as a flag\n --site-env <env> Environment whose A/B variant applies (default: production)\n\n Checks the schema, then the things a schema cannot: that every role points at a\n palette hue that exists, that every var() an authored declaration references\n resolves to a token this config emits, and that the colour grammar is current.\n\nFavicon options:\n -i, --input <path> Source SVG or PNG (required)\n -o, --out <dir> Output directory (default: ./public)\n --low-res <path> Optional simplified source for the 16px icon\n --background <hex> Background under the maskable icons, which must be\n opaque (default: #ffffff)\n\nAgents options:\n -i, --input <path> design-system.json or site config (default: ./design-system.json;\n validated if present)\n --theme <name> Theme to read, when --input is a site config\n -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)\n --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir\n instead of linking the packaged skill\n\nDocs options:\n <topic> classes | authoring | formats | color | scales | patterns | elements\n (no topic: list topics with summaries)\n -i, --input <path> design-system.json or site config (default: ./design-system.json)\n --theme <name> Theme to render docs from, when --input is a site config\n --all Print every topic, concatenated\n\nIcons options:\n -i, --input <path> Site config carrying the \"icons\" block (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n --src <dir> Source to scan for icon usage (default: ./src)\n --sprite Also build the SVG sprite\n -o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)\n --json Machine-readable report on stdout\n\nLint options:\n -i, --input <path> design-system.json or site config (default: ./design-system.json)\n --theme <name> Theme to judge classes against, when --input is a site config\n -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).\n Responsive md-* classes are real in css/bricks, inert in tailwind.\n -s, --src <dir> Directory to scan (default: ./src)\n --strict Also fail on suggestions (reuse hints), not just on\n classes that resolve to nothing\n [files...] Lint these paths instead of scanning --src. Unreadable\n and non-source paths are skipped, so this can be wired\n into a pre-commit hook that appends the staged files.\n --fix Rewrite pre-1.0 colour-grammar token references\n (var(--<role>-<suffix>) → var(--color-<target>-<role>…))\n in CSS and <style> blocks. Applied in ONE pass, so the\n renames that rotate cannot compound. Nothing else is\n rewritten — every other finding is a judgement call.\n\n Run this after a major bump: it is the migration tool for anything the\n changelog says was renamed.\n\nLegal options:\n -i, --input <path> Site config: .json, or a .js/.ts module with a default\n export (default: ./site.json)\n -d, --doc <name> privacy | terms | cookies (repeatable; default: those\n enabled in the config)\n -f, --format <fmt> md | html | portable-text (default: md)\n html suits WordPress/Bricks and plain sites;\n portable-text suits EmDash\n -o, --out <dir> Write files here (default: print to stdout)\n --site-env <env> Environment whose A/B variant applies (default: production)\n\n Generated from your config — a starting point, not legal advice. Review before\n publishing, and make sure the config describes what your site actually does.\n\nRobots options:\n -i, --input <path> Site config: .json, or a .js/.ts module with a default\n export (default: ./site.json)\n -o, --out <dir> Write robots.txt here (default: print to stdout)\n --site-env <env> Which site.environments entry to render for (default: production)\n\n Reads the resolved environment's robots directive (falling back to\n site.seo.robots) — the same fact vitops search notify's noindex gate already\n reads. A directive containing noindex disallows every user agent and emits\n no Sitemap line; otherwise it allows everything and links\n site.seo.indexing.sitemapUrl, or the derived <canonical>/sitemap-index.xml.\n\nReviews options:\n -i, --input <path> Site config carrying site.reviews (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n --location <key> Fetch only this site.reviews.locations key\n --cache <path> Where to read/write the cache (default: .vitops/reviews.json)\n --out <dir> Also write the selected reviews per location as JSON here\n --dry Print the plan and exit. Fetches nothing.\n --check Fetch and diff against the cache; exit non-zero on drift.\n Mutates nothing.\n\n Reviews live only on the legacy Business Profile v4 API — there is no newer\n replacement — and the calling GCP project must be allowlisted by Google before\n any call succeeds (a one-time approval; a 403 says so rather than failing\n opaquely). Auth is user OAuth, the same VITOPS_GOOGLE_CLIENT_ID /\n VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (or ADC login) that\n vitops search setup uses, scoped to business.manage. The cache is meant to be\n committed: reviews change slowly, and a build should not need a live Google\n credential. Review JSON-LD is off by default (site.reviews.schema) — a business\n marking up reviews of itself is not eligible for Google's rich results and\n risks a manual action.\n\nSearch subcommands:\n vitops search setup [opts] Onboard site.searchConsole domains into GSC\n vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)\n\nSearch setup options:\n -i, --input <path> Site config carrying site.searchConsole (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n --domain <name> Scope to a single site.searchConsole entry\n --dry Print the plan and exit. Changes nothing, and runs without\n credentials (planning from scratch, which it says).\n --check Report drift and exit non-zero if any domain is not fully\n onboarded. Mutates nothing, but does need credentials —\n drift is a comparison against live state.\n\n For each domain it ensures the apex verification TXT in Cloudflare, verifies\n ownership (DNS_TXT, retried with backoff while DNS propagates — a still-\n unverified domain is reported PENDING, not failed), adds the sc-domain: property,\n and adds any delegatedOwners to the web resource. DNS is only ever created,\n never edited or deleted. Credentials come from the environment:\n CLOUDFLARE_API_TOKEN (Zone:DNS:Edit + Zone:Read — the standard \"Edit zone DNS\"\n template covers both; Zone:Read is what the zone-by-name lookup uses), and\n VITOPS_GOOGLE_CLIENT_ID /\n VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token\n scoped to siteverification + webmasters). Granting a Google Group Full-User\n access has no API and is surfaced as a reminder.\n\n Or just be logged in: with no VITOPS_GOOGLE_* set, an Application Default\n Credentials login is used —\n gcloud auth application-default login \\\n --scopes=openid,https://www.googleapis.com/auth/siteverification,\\\n https://www.googleapis.com/auth/webmasters,https://www.googleapis.com/auth/cloud-platform\n ADC authenticates through a shared OAuth client that owns no project, so it also\n needs site.google.project in the config (sent as x-goog-user-project), both APIs\n enabled on that project, and serviceusage.services.use on it. One project per site\n is what keeps each site's API usage and billing separate.\n\nSearch notify options:\n -i, --input <path> Site config carrying seo.indexing (default: ./site.json)\n --site-env <env> Environment to notify for (default: production). An\n environment whose robots policy says noindex is refused.\n --sitemap <src> Sitemap URL or local path (default: from the config)\n --urls <list> Comma-separated URLs to submit, skipping the diff\n --all Submit every URL in the sitemap, not just what changed\n --dry Print the plan and exit. Makes no requests.\n --check Read-only: ask Google whether seo.indexing.priorityUrls\n are indexed. Exits non-zero if one is not.\n --new-key Print a fresh IndexNow key and exit\n --write-key <dir> Write the IndexNow key file into <dir> (for stacks with\n no Astro integration to do it — Bricks, WordPress)\n --snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).\n Persist it between runs (a CI cache) or every run submits\n everything.\n\n What notify can and cannot do: Google exposes no \"request indexing\" API, and its\n sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap\n through the Search Console API and verifies the result with --check. IndexNow\n reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.\n Search Console takes either credential, whichever you already have, added as an\n owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline\n JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth\n credential \"search setup\" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /\n _REFRESH_TOKEN). The service account is preferred when both are set — this runs\n on every deploy, and it does not expire.\n\nAds subcommands:\n vitops ads setup [opts] Ensure each platform's domain-verification DNS record\n vitops ads tags [opts] Print the consent-gated pixel snippets\n vitops ads lint [opts] Report ad properties the rest of the config can't see\n\nAds setup options:\n -i, --input <path> Site config carrying site.ads (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n --provider <name> Scope to one site.ads entry (google | meta | linkedin |\n reddit | tiktok | microsoft | pinterest | snapchat)\n --dry Print the plan and exit. Creates nothing. With\n CLOUDFLARE_API_TOKEN set it reads live DNS first, so the\n plan says what is already done; without one it plans from\n scratch and says so, rather than refusing to run.\n --check Report drift and exit non-zero if any property is not\n linked. Mutates nothing, prompts for nothing.\n --no-prompt Never ask for a missing token; fail with the field name\n instead (the default when stdin is not a TTY, e.g. in CI)\n --no-write Don't persist an answered token back into the config\n\n Four platforms verify a domain by DNS TXT — Meta, TikTok, Pinterest, Snapchat —\n and that record is the one thing this creates for you (in Cloudflare, via\n CLOUDFLARE_API_TOKEN; created only, never edited or deleted). Google Ads,\n LinkedIn, Reddit and Microsoft Ads have no domain verification at all: linking\n there is the tag and the account ID, and the run says so rather than skipping in\n silence. No platform Marketing API is called — Meta's needs a system-user token\n and Google's needs an approved developer token, so the final \"Verify\" click stays\n a reminder. The verification token is not a secret (it is published in DNS, which\n is the ownership proof), so it lives in the config and is prompted for on the\n first run.\n\nAds tags options:\n -i, --input <path> Site config carrying site.ads (default: ./site.json)\n --provider <name> Print one platform's tag\n --strategy <s> idle | async | interaction (default: idle)\n\n Prints each pixel as an INERT, consent-gated <script>: type=\"text/plain\" with the\n library URL on data-src, so an undecided visitor's page issues no third-party\n request. For stacks with no Astro integration — Bricks, WordPress, Eleventy.\n Paste into your template; @getvitops/core's consent runtime activates them.\n\nAds lint options:\n -i, --input <path> Site config carrying site.ads (default: ./site.json)\n\n Reports the gaps that are invisible at runtime: a click-ID parameter this\n platform stamps that attribution doesn't capture, a pixel while site.tracking is\n off (every conversion arrives unattributed), and a configured property with no\n tag ID. Exits non-zero on a finding.\n\nCrm check options:\n -i, --input <path> Site config carrying site.crm (default: ./site.json)\n --site-env <env> Environment to resolve (default: production)\n\n Fetches the live field/property list (HubSpot: crm/v3/properties/contacts;\n Bigin: bigin/v2/settings/fields) and reports every fieldMap/trackingFields\n target that doesn't exist there. HubSpot rejects a submission naming an\n unrecognised property; Bigin silently ignores it — this is the only way to\n see a broken mapping before a real submission finds it. Needs\n VITOPS_HUBSPOT_ACCESS_TOKEN, VITOPS_ZOHO_CLIENT_ID/_CLIENT_SECRET/\n _REFRESH_TOKEN, or (unused for now) VITOPS_JOBBER_CLIENT_ID/_CLIENT_SECRET/\n _REFRESH_TOKEN, matching whichever provider site.crm names. Jobber currently\n renders fieldMap/trackingFields as note-text lines rather than app-configured\n custom fields, so there is nothing live to check there yet. Exits non-zero\n on any missing target.\n\nCrm setup options:\n -i, --input <path> Site config carrying site.crm (default: ./site.json)\n --site-env <env> Environment to resolve (default: production)\n --dry Print what would be created and exit. Creates nothing.\n\n HubSpot: creates missing contact properties named by fieldMap/trackingFields.\n Bigin: no such API exists, so this always prints a manual reminder listing\n the fields to add in Zoho Bigin's field editor, then re-run \"crm check\".\n Jobber: app-configured custom fields aren't wired up yet, so this prints a\n reminder instead of creating anything.\n\nSetup repo options:\n --dry Print the plan and exit\n --check Report drift/blocked steps and exit non-zero, mutating nothing\n --public Create the repo public (default: private)\n --platform <p> cloudflare (default) | node — shown for parity with the hosting stage's plan\n --no-dev-resources Same — this stage never provisions D1/R2 either way\n\n Creates the GitHub repo (via `gh`), pushes main + dev, makes dev the default\n branch, and requests main branch protection + the production/dev environments.\n Idempotent — a re-run of a fully set-up repo is all skips. Branch protection and\n environment reviewers 403 on a private repo without GitHub Pro/Team; that is\n reported as blocked, with the manual Settings path, never as a failure.\n\nSetup hosting options:\n --dry Print the plan and exit\n --check Report drift/blocked steps and exit non-zero, mutating nothing\n --platform <p> cloudflare (default) | node\n --no-dev-resources Skip D1/R2 — a pure static Workers Assets project has\n nothing to provision, just the account id + token\n\n Provisions the isolated dev D1 database + R2 bucket (cloudflare) and sets the\n DEV_D1_ID / CLOUDFLARE_ACCOUNT_ID repo secrets, once `vitops setup repo` has run.\n CLOUDFLARE_API_TOKEN is always reported as blocked — no API can create one; see\n the printed dashboard link. Requires `npx wrangler login` first.\n\nSetup domain options:\n -i, --input <path> Site config (default: ./site.json)\n --site-env <env> Environment to check (default: production)\n --dry Print the plan and exit\n --check Exit non-zero if the domain state is inconsistent\n --domain <apex> Print the manual cutover steps for this apex\n\n Plans and checks the consistency between wrangler.jsonc's workers.dev/custom-domain\n state and site.json's environments/domains/robots — it NEVER edits wrangler.jsonc\n or deploys. Cutover overwrites the zone's DNS record and can take an existing live\n site down; that step is manual, on purpose. See SETUP.html's \"Domain strategy\" stage.\n\nSetup search options:\n Forwards every option to `vitops search setup` unchanged — see \"Search setup\n options:\" above.\n\nSetup ads options:\n Forwards every option to `vitops ads setup` unchanged — see \"Ads setup options:\"\n above.\n\nSetup all options:\n --dry Print every stage's plan and exit\n --check Report drift across every stage and exit non-zero\n --public Create the repo public, for the repo stage\n --platform <p> cloudflare (default) | node, for the repo + hosting stages\n --no-dev-resources Skip D1/R2 in the hosting stage (repo stage never provisions them)\n\n Runs repo, hosting, search, ads in order. Stops at the first stage that fails;\n a blocked step (the API token, GitHub Pro/Team gating) is reported, not fatal.\n\nEmail subcommands:\n vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve\n vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded\n\nEmail setup options:\n -i, --input <path> Site config carrying site.notifications.email (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n [domain] Onboard this exact name instead of deriving candidates from\n config + a source scan\n --src <dir> Source to scan for from: literals as a supplement to config\n (default: ./src)\n --dry Print the plan and exit. Needs no credential — plans from\n scratch and says so.\n --check Report drift and exit non-zero. Mutates nothing.\n --no-prompt Never ask before enabling a domain (the default when\n stdin is not a TTY, e.g. in CI — it just enables)\n --timeout <s> Cap on each DNS-propagation poll wait, in seconds (default: 5)\n\n The onboarded entity is an EXACT name, and it is usually a subdomain\n (send.example.com), never the apex a from address names. When the exact\n domain is absent but a sibling in the same zone is already onboarded, this\n is reported blocked rather than silently enabling the apex too — enabling\n it rewrites _dmarc.<domain>, which can tighten policy on every OTHER mail\n sent from that domain. Sending from the onboarded sibling is the\n recommended fix. Needs CLOUDFLARE_API_TOKEN (Zone:Read + an Email Sending\n scope — broader than the \"Edit zone DNS\" template other commands use).\n\nEmail check options:\n -i, --input <path> Site config carrying site.notifications.email (default: ./site.json)\n --site-env <env> Environment whose A/B variant applies (default: production)\n --src <dir> Source to scan for from: literals (default: ./src)\n --strict Exit non-zero on drift (the default just warns and exits 0)\n --skip Skip the check entirely (same as VITOPS_SKIP_EMAIL_CHECK)\n\n Meant to run before a deploy. With no CLOUDFLARE_API_TOKEN it reports \"not\n checked\" and exits 0, so a CI job without the secret never fails on the\n check itself. Skip per-site with site.notifications.email.verifySender: false\n when the scan can't see the real from address and the warning would be a\n false positive.\n\nMedia options:\n --raw <dir> Directory of unprocessed video, walked recursively\n (default: ./raw)\n -o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).\n Subdirectories under --raw are preserved.\n --max-width <px> Cap the output width, keeping aspect ratio (default: 1920;\n 0 disables scaling)\n --crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).\n The MP4 fallback uses the equivalent on H.264's scale.\n --max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is\n constant quality rather than constrained)\n --audio Keep the audio track (default: dropped — the common case is\n a muted autoplay loop)\n --poster-time <s> Timestamp the poster frame is taken from (default: 0, which\n is often black on a clip that fades in)\n --outputs <list> Comma-separated: webm | mp4 | poster (default: all three)\n --manifest <path> Cache file (default: .vitops/media-manifest.json)\n --force Re-encode everything, ignoring the cache\n --dry Print the plan and exit. Encodes nothing.\n\n Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this\n command fails rather than quietly skipping. Commit the outputs and the manifest:\n ffmpeg output is not reproducible across versions, so re-encoding in CI churns\n the diff on every toolchain bump. Use --force when you mean to re-encode.\n\nCommon:\n -h, --help Show this help\n";
740
853
  }
741
854
 
742
- //#endregion
743
- //#region src/commands/search.ts
855
+ // src/commands/search.ts
744
856
  /**
745
857
  * `vitops search` — everything that talks to search engines: `setup` onboards a
746
858
  * domain into Search Console (DNS + verification + property), `notify` tells the
@@ -750,10 +862,9 @@ function helpFor(command, argv) {
750
862
  /**
751
863
  * Adapt a `Config` to the indexing module's own option shape.
752
864
  *
753
- * A flat field map, as intended: `@getvitops/utils` cannot import from the
754
- * generator (the generator already depends on it), so `IndexingConfig` mirrors the
755
- * `seo.indexing` block rather than being it. The one piece of judgment here is the
756
- * origin — see below.
865
+ * `IndexNowConfig` and `SearchConsoleConfig` are the schema's own types now, so those blocks pass
866
+ * straight through. What this adapter still does is genuine resolution — the origin and the
867
+ * robots policy come from the resolved environment, not from the `seo.indexing` block — see below.
757
868
  */
758
869
  function toIndexingConfig(cfg, siteEnv) {
759
870
  const site = cfg.site;
@@ -772,17 +883,17 @@ function toIndexingConfig(cfg, siteEnv) {
772
883
  /**
773
884
  * Adapt a `Config` to the onboarding module's own option shape.
774
885
  *
775
- * The same flat field map as `toIndexingConfig`, and for the same reason — utils
776
- * cannot import the generator. `--domain` scopes to a single `site.searchConsole`
777
- * entry, and a name that isn't there is an error, not an empty run.
886
+ * `DomainSetup` is the `site.searchConsole.<domain>` block plus its key, so this is a spread and a
887
+ * filter. `--domain` scopes to a single entry, and a name that isn't there is an error, not an
888
+ * empty run.
778
889
  */
779
890
  function toSearchConsoleSetup(cfg, domainFilter) {
780
891
  const entries = cfg.site.searchConsole ?? {};
781
- return pickOne(Object.entries(entries).map(([domain, e]) => ({
782
- domain,
783
- ...e.delegatedOwners ? { delegatedOwners: e.delegatedOwners } : {},
784
- ...e.fullUserGroup ? { fullUserGroup: e.fullUserGroup } : {}
785
- })), domainFilter, (s) => s.domain, {
892
+ const setups = Object.entries(entries).map(([domain, e]) => ({
893
+ ...e,
894
+ domain
895
+ }));
896
+ return pickOne(setups, domainFilter, (s) => s.domain, {
786
897
  flag: "domain",
787
898
  source: "site.searchConsole"
788
899
  });
@@ -815,7 +926,7 @@ function searchAuth(token, credential, cfg, input) {
815
926
  quotaProject: project
816
927
  } : token;
817
928
  }
818
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
929
+ const sleep$1 = (ms) => new Promise((r) => setTimeout(r, ms));
819
930
  /**
820
931
  * Tell search engines a deploy happened.
821
932
  *
@@ -941,13 +1052,16 @@ async function cmdSearchNotify(argv) {
941
1052
  if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
942
1053
  }
943
1054
  }
944
- if (p.searchConsole.enabled) if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
945
- else {
946
- const r = await submitSitemap(searchAuth(await googleAccessToken(credential), credential, site, a.str("input")), p.searchConsole.siteUrl, sitemapUrl);
947
- if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1055
+ if (p.searchConsole.enabled) {
1056
+ if (!credential) console.log(`· Search Console: skipped — no credential (${CREDENTIAL_HINT})`);
948
1057
  else {
949
- console.error(`✖ Search Console: ${r.message}`);
950
- failed = true;
1058
+ const auth = searchAuth(await googleAccessToken(credential), credential, site, a.str("input"));
1059
+ const r = await submitSitemap(auth, p.searchConsole.siteUrl, sitemapUrl);
1060
+ if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1061
+ else {
1062
+ console.error(`✖ Search Console: ${r.message}`);
1063
+ failed = true;
1064
+ }
951
1065
  }
952
1066
  }
953
1067
  if (failed) process.exit(1);
@@ -1088,7 +1202,7 @@ async function cmdSearchSetup(argv) {
1088
1202
  if (attempt < delays.length - 1) {
1089
1203
  const wait = delays[attempt];
1090
1204
  console.log(` … ${d.domain}: not verified yet (attempt ${attempt + 1}/${delays.length}); retrying in ${wait / 1e3}s`);
1091
- await sleep(wait);
1205
+ await sleep$1(wait);
1092
1206
  }
1093
1207
  }
1094
1208
  result.verified = verified ? "yes" : "pending";
@@ -1138,8 +1252,7 @@ async function cmdSearch(argv) {
1138
1252
  }
1139
1253
  }
1140
1254
 
1141
- //#endregion
1142
- //#region src/prompt.ts
1255
+ // src/prompt.ts
1143
1256
  /**
1144
1257
  * Asking for the config values `vitops ads setup` cannot derive, and writing the
1145
1258
  * answers back.
@@ -1276,8 +1389,7 @@ function writeConfigPatch(path, provider, field, value) {
1276
1389
  return { written: true };
1277
1390
  }
1278
1391
 
1279
- //#endregion
1280
- //#region src/commands/ads.ts
1392
+ // src/commands/ads.ts
1281
1393
  /**
1282
1394
  * `vitops ads` — everything that links this site to an ad platform. Three
1283
1395
  * subcommands, split by what they touch: `setup` writes DNS, `tags` writes
@@ -1286,9 +1398,10 @@ function writeConfigPatch(path, provider, field, value) {
1286
1398
  /**
1287
1399
  * Adapt a `Config` to the ads module's own option shape.
1288
1400
  *
1289
- * The same flat field map as `toSearchConsoleSetup` and `toIndexingConfig`, for the
1290
- * same reason — utils cannot import the generator. The domain default is the one
1291
- * derived value: a platform verifies the host the site is published at, so
1401
+ * `AdPropertySetup` is the `site.ads.<provider>` block plus its key, so this spreads the block.
1402
+ * (It used to copy a hand-picked subset of fields, which silently dropped `conversionActionId` and
1403
+ * `reporting` on the way to the planner.) The domain default is the one derived value: a platform
1404
+ * verifies the host the site is published at, so
1292
1405
  * `domains.canonical` answers it, and only an ad account verified against some
1293
1406
  * other host needs `site.ads.<provider>.domain` stated.
1294
1407
  */
@@ -1307,14 +1420,9 @@ function toAdProperties(cfg, providerFilter) {
1307
1420
  const e = entries[provider];
1308
1421
  const domain = e.domain ?? canonicalHost;
1309
1422
  return {
1423
+ ...e,
1310
1424
  provider,
1311
- ...e.accountId ? { accountId: e.accountId } : {},
1312
- ...e.pixelId ? { pixelId: e.pixelId } : {},
1313
- ...e.conversionLabel ? { conversionLabel: e.conversionLabel } : {},
1314
- ...domain ? { domain } : {},
1315
- ...e.domainVerification ? { domainVerification: e.domainVerification } : {},
1316
- ...e.category ? { category: e.category } : {},
1317
- ...e.enabled != null ? { enabled: e.enabled } : {}
1425
+ ...domain ? { domain } : {}
1318
1426
  };
1319
1427
  });
1320
1428
  if (providerFilter && !isAdProvider(providerFilter)) fail(`--provider "${providerFilter}" is not a known ad platform (expected: ${AD_PROVIDER_KEYS.join(" | ")})`);
@@ -1513,7 +1621,8 @@ async function cmdAdsTags(argv) {
1513
1621
  * with tracking off produces conversions no campaign gets credit for.
1514
1622
  */
1515
1623
  async function cmdAdsLint(argv) {
1516
- const cfg = await siteConfig(parse$1(argv, SITE_OPTS));
1624
+ const a = parse$1(argv, SITE_OPTS);
1625
+ const cfg = await siteConfig(a);
1517
1626
  const properties = toAdProperties(cfg, void 0);
1518
1627
  if (properties.length === 0) {
1519
1628
  console.log("· no site.ads properties — nothing to check");
@@ -1528,6 +1637,12 @@ async function cmdAdsLint(argv) {
1528
1637
  const uncaptured = platform.clickIdParams.filter((param) => !(param in PLATFORM_PARAMS));
1529
1638
  if (uncaptured.length) findings.push(`${p.provider}: click ID ${uncaptured.join(", ")} is not in the capture vocabulary — every conversion from this platform will arrive unattributed`);
1530
1639
  if (!trackingOn) findings.push(`${p.provider}: site.tracking.enabled is not true, so ${platform.clickIdParams.join("/")} is never captured — conversions will be unattributed`);
1640
+ if (p.provider === "google" && p.reporting === "server") {
1641
+ const labels = normalizeConversionLabels(p.conversionLabel);
1642
+ const actionIds = normalizeConversionActionIds(p.conversionActionId);
1643
+ const configuredNoId = Object.keys(labels).filter((action) => labels[action] && !actionIds[action]);
1644
+ if (configuredNoId.length) findings.push(`google: reporting is "server" but conversionActionId.${configuredNoId.join(", .")} is not set — ${configuredNoId.length === 1 ? "that action" : "those actions"} still fire client-side, not a failure but not what "server" asked for either`);
1645
+ }
1531
1646
  }
1532
1647
  if (findings.length === 0) {
1533
1648
  console.log(`✓ ${properties.length} ad propert${properties.length === 1 ? "y" : "ies"} check out`);
@@ -1551,8 +1666,505 @@ async function cmdAds(argv) {
1551
1666
  }
1552
1667
  }
1553
1668
 
1554
- //#endregion
1555
- //#region src/commands/setup.ts
1669
+ // src/commands/crm.ts
1670
+ /**
1671
+ * `vitops crm` — the two things `site.crm` needs a human-in-the-loop for.
1672
+ *
1673
+ * `check` is the drift guard, and the reason this command exists at all:
1674
+ * HubSpot *rejects* a submission naming an unrecognised property, and Bigin
1675
+ * silently *ignores* one — so a typo'd `fieldMap`/`trackingFields` entry is
1676
+ * invisible in one CRM and fatal in the other, and nothing short of fetching
1677
+ * the live field list can tell you which. `setup` creates the missing
1678
+ * properties HubSpot's API allows creating; Bigin has no such endpoint, so it
1679
+ * always resolves to a manual reminder, matching how `ads setup` handles a
1680
+ * platform with no verification API. Jobber currently renders `fieldMap`/
1681
+ * `trackingFields` values as note-text lines rather than app-configured
1682
+ * custom fields (see `@getvitops/utils/crm`'s `jobber.ts` docblock for why
1683
+ * that surface is deferred), so there is nothing live to check yet — both
1684
+ * subcommands say so rather than guessing at an unverified API.
1685
+ *
1686
+ * Modelled on `commands/ads.ts`: read-and-create only, never destructive.
1687
+ */
1688
+ /** Every CRM property name this site's config asserts exists, from both maps —
1689
+ * `fieldMap`/`trackingFields` values are property names, not the submitted
1690
+ * field names on the left. */
1691
+ function configuredProperties(crm) {
1692
+ return [...Object.values(crm.fieldMap ?? {}), ...Object.values(crm.trackingFields ?? {})];
1693
+ }
1694
+ async function hubspotContactProperties(accessToken) {
1695
+ const res = await fetch("https://api.hubapi.com/crm/v3/properties/contacts", { headers: { authorization: `Bearer ${accessToken}` } });
1696
+ if (!res.ok) fail(`HubSpot properties lookup failed (${res.status}): ${await res.text()}`);
1697
+ const json = await res.json();
1698
+ return new Set((json.results ?? []).map((p) => p.name));
1699
+ }
1700
+ async function biginContactFields(oauth, dataCenter) {
1701
+ const body = new URLSearchParams({
1702
+ grant_type: "refresh_token",
1703
+ client_id: oauth.clientId,
1704
+ client_secret: oauth.clientSecret,
1705
+ refresh_token: oauth.refreshToken
1706
+ });
1707
+ const tokenRes = await fetch(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {
1708
+ method: "POST",
1709
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1710
+ body: body.toString()
1711
+ });
1712
+ if (!tokenRes.ok) fail(`Zoho token exchange failed (${tokenRes.status}): ${await tokenRes.text()}`);
1713
+ const { access_token } = await tokenRes.json();
1714
+ if (!access_token) fail("Zoho token exchange returned no access_token");
1715
+ const res = await fetch(`https://${zohoApiHost(dataCenter)}/bigin/v2/settings/fields?module=Contacts`, { headers: { authorization: `Zoho-oauthtoken ${access_token}` } });
1716
+ if (!res.ok) fail(`Bigin fields lookup failed (${res.status}): ${await res.text()}`);
1717
+ const json = await res.json();
1718
+ return new Set((json.fields ?? []).map((f) => f.api_name));
1719
+ }
1720
+ /** What each provider's `check` needs to say when it doesn't fail outright.
1721
+ * Kept as a table rather than an `if/else if` chain so a fourth provider is
1722
+ * a missing entry (a type error), not a silently-wrong branch. */
1723
+ const CHECK_NOUNS = {
1724
+ hubspot: "properties",
1725
+ bigin: "fields",
1726
+ jobber: "fields"
1727
+ };
1728
+ const CHECK_REJECTION = {
1729
+ hubspot: "HubSpot will reject the whole submission",
1730
+ bigin: "Bigin will silently drop this value",
1731
+ jobber: "Jobber will silently drop this value"
1732
+ };
1733
+ /**
1734
+ * Fetch the live field/property vocabulary and report every `fieldMap`/
1735
+ * `trackingFields` target that doesn't exist there.
1736
+ */
1737
+ async function cmdCrmCheck(argv) {
1738
+ const a = parse$1(argv, SITE_OPTS);
1739
+ const crm = (await siteConfig(a)).site.crm;
1740
+ if (!crm) {
1741
+ console.log("· no site.crm — nothing to check");
1742
+ return;
1743
+ }
1744
+ if (crm.enabled === false) {
1745
+ console.log("· site.crm.enabled is false — nothing to check");
1746
+ return;
1747
+ }
1748
+ const wanted = configuredProperties(crm);
1749
+ if (wanted.length === 0) {
1750
+ console.log("· site.crm has no fieldMap/trackingFields entries — nothing to check");
1751
+ return;
1752
+ }
1753
+ if (crm.provider === "jobber") {
1754
+ 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).");
1755
+ return;
1756
+ }
1757
+ let known;
1758
+ if (crm.provider === "hubspot") {
1759
+ const token = loadHubSpotToken();
1760
+ if (!token) fail("set VITOPS_HUBSPOT_ACCESS_TOKEN to check the live property list");
1761
+ known = await hubspotContactProperties(token);
1762
+ } else {
1763
+ const oauth = loadZohoOAuth();
1764
+ if (!oauth) fail("set VITOPS_ZOHO_CLIENT_ID / VITOPS_ZOHO_CLIENT_SECRET / VITOPS_ZOHO_REFRESH_TOKEN to check the live field list");
1765
+ known = await biginContactFields(oauth, crm.bigin.dataCenter);
1766
+ }
1767
+ const missing = wanted.filter((p) => !known.has(p));
1768
+ if (missing.length === 0) {
1769
+ console.log(`✓ ${wanted.length} mapped ${CHECK_NOUNS[crm.provider]} exist`);
1770
+ return;
1771
+ }
1772
+ for (const p of missing) console.error(`✖ "${p}" is not a ${crm.provider === "hubspot" ? "HubSpot contact property" : "Bigin Contacts field"} — ${CHECK_REJECTION[crm.provider]}`);
1773
+ process.exit(1);
1774
+ }
1775
+ /**
1776
+ * Create the HubSpot properties `fieldMap`/`trackingFields` name but don't
1777
+ * exist yet. Bigin has no property-creation API and Jobber's app-configured
1778
+ * custom fields aren't wired up yet (see the module docblock) — both resolve
1779
+ * to `blocked`/a manual reminder, matching `ads setup`'s handling of a
1780
+ * platform with no verification API.
1781
+ */
1782
+ async function cmdCrmSetup(argv) {
1783
+ const a = parse$1(argv, {
1784
+ ...SITE_OPTS,
1785
+ dry: {
1786
+ type: "boolean",
1787
+ default: false
1788
+ }
1789
+ });
1790
+ const crm = (await siteConfig(a)).site.crm;
1791
+ if (!crm) fail("no site.crm configured — nothing to set up");
1792
+ const wanted = configuredProperties(crm);
1793
+ if (crm.provider === "jobber") {
1794
+ 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.");
1795
+ return;
1796
+ }
1797
+ if (crm.provider === "bigin") {
1798
+ if (wanted.length === 0) {
1799
+ console.log("· no fieldMap/trackingFields entries to check against Bigin");
1800
+ return;
1801
+ }
1802
+ 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"));
1803
+ return;
1804
+ }
1805
+ const token = loadHubSpotToken();
1806
+ if (!token) fail("set VITOPS_HUBSPOT_ACCESS_TOKEN to create HubSpot properties");
1807
+ const known = await hubspotContactProperties(token);
1808
+ const missing = wanted.filter((p) => !known.has(p));
1809
+ if (missing.length === 0) {
1810
+ console.log("✓ every mapped property already exists");
1811
+ return;
1812
+ }
1813
+ if (a.flag("dry")) {
1814
+ console.log(`would create ${missing.length} HubSpot contact propert${missing.length === 1 ? "y" : "ies"}:`);
1815
+ for (const p of missing) console.log(` + ${p}`);
1816
+ return;
1817
+ }
1818
+ for (const p of missing) {
1819
+ const res = await fetch("https://api.hubapi.com/crm/v3/properties/contacts", {
1820
+ method: "POST",
1821
+ headers: {
1822
+ authorization: `Bearer ${token}`,
1823
+ "content-type": "application/json"
1824
+ },
1825
+ body: JSON.stringify({
1826
+ name: p,
1827
+ label: p,
1828
+ type: "string",
1829
+ fieldType: "text",
1830
+ groupName: "contactinformation"
1831
+ })
1832
+ });
1833
+ if (!res.ok) {
1834
+ console.error(`✖ ${p}: ${res.status} ${await res.text()}`);
1835
+ process.exit(1);
1836
+ }
1837
+ console.log(`✓ created ${p}`);
1838
+ }
1839
+ }
1840
+ async function cmdCrm(argv) {
1841
+ const [sub, ...rest] = argv;
1842
+ switch (sub) {
1843
+ case "check": return cmdCrmCheck(rest);
1844
+ case "setup": return cmdCrmSetup(rest);
1845
+ case void 0:
1846
+ case "-h":
1847
+ case "--help":
1848
+ console.log(CRM_HELP);
1849
+ return;
1850
+ default: fail(`unknown "crm" subcommand "${sub}" (expected: check | setup). Try: vitops --help`);
1851
+ }
1852
+ }
1853
+
1854
+ // src/commands/email.ts
1855
+ /**
1856
+ * `vitops email` — connect `site.notifications.email.from` to whether its domain
1857
+ * is actually onboarded onto an outbound email provider.
1858
+ *
1859
+ * The silent failure this exists to catch: `EMAIL.send()` throws
1860
+ * `E_SENDER_NOT_VERIFIED` at runtime with nothing at build or deploy time saying
1861
+ * a word, because nothing previously checked the `from` domain against Cloudflare
1862
+ * Email Sending's actual onboarding state. Two subcommands, split by what they
1863
+ * do: `setup` onboards a domain and waits for its DNS to resolve; `check` is the
1864
+ * non-blocking deploy guard.
1865
+ */
1866
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1867
+ /**
1868
+ * Every `from` domain worth checking: the explicit config value first, its
1869
+ * derived `noreply@<canonical>` default second, and a best-effort scan of
1870
+ * `--src` as a supplement — never the primary source, since a computed value in
1871
+ * source is reported as dynamic rather than guessed at (see `email/scan.ts`).
1872
+ */
1873
+ function gatherCandidates(cfg, srcDir) {
1874
+ const candidates = [];
1875
+ const dynamicNotes = [];
1876
+ const channel = normalizeEmailChannel(cfg.site.notifications?.email);
1877
+ const canonical = cfg.site.domains?.canonical;
1878
+ const address = resolveSender(channel ?? { provider: "cloudflare" }, { canonical });
1879
+ if (address) {
1880
+ const domain = address.split("@")[1];
1881
+ if (domain) candidates.push({
1882
+ address,
1883
+ domain,
1884
+ source: channel?.from ? "config" : "derived"
1885
+ });
1886
+ }
1887
+ if (existsSync(srcDir)) {
1888
+ const { addresses, dynamic } = collectFromAddresses(scanFiles(srcDir));
1889
+ for (const a of addresses) {
1890
+ const domain = a.split("@")[1];
1891
+ if (domain && !candidates.some((c) => c.address === a)) candidates.push({
1892
+ address: a,
1893
+ domain,
1894
+ source: "scan"
1895
+ });
1896
+ }
1897
+ for (const d of dynamic) dynamicNotes.push(`! ${d.file}:${d.line}: from is computed (\`${d.expr}\`) — not checked`);
1898
+ }
1899
+ return {
1900
+ candidates,
1901
+ dynamicNotes
1902
+ };
1903
+ }
1904
+ /** Observe each candidate domain's zone, keyed by the domain itself (not the zone name). */
1905
+ async function observeZones(token, domains) {
1906
+ const states = /* @__PURE__ */ new Map();
1907
+ const failures = [];
1908
+ if (!token) return {
1909
+ states,
1910
+ failures
1911
+ };
1912
+ for (const domain of domains) {
1913
+ const zone = await findZoneForHost(token, domain);
1914
+ if (!zone.ok || !zone.zoneId) {
1915
+ failures.push(`${domain}: ${zone.message}`);
1916
+ continue;
1917
+ }
1918
+ const res = await listSendingSubdomains(token, zone.zoneId);
1919
+ if (!res.ok) {
1920
+ failures.push(`${domain}: ${res.message}`);
1921
+ continue;
1922
+ }
1923
+ states.set(domain, {
1924
+ zoneId: zone.zoneId,
1925
+ subdomains: res.subdomains
1926
+ });
1927
+ }
1928
+ return {
1929
+ states,
1930
+ failures
1931
+ };
1932
+ }
1933
+ async function confirm(question) {
1934
+ const rl = createInterface({
1935
+ input: process.stdin,
1936
+ output: process.stdout
1937
+ });
1938
+ try {
1939
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
1940
+ return answer === "y" || answer === "yes";
1941
+ } finally {
1942
+ rl.close();
1943
+ }
1944
+ }
1945
+ /**
1946
+ * Onboard a `from` domain and wait for its DNS to resolve.
1947
+ *
1948
+ * Idempotent by construction — a re-run of a fully-onboarded domain is all
1949
+ * skips. `--dry` prints the plan and mutates nothing (and needs no credential,
1950
+ * planning from scratch); `--check` reports drift and exits non-zero.
1951
+ */
1952
+ async function cmdEmailSetup(argv) {
1953
+ const a = parse$1(argv, {
1954
+ ...SITE_OPTS,
1955
+ src: {
1956
+ type: "string",
1957
+ default: "src"
1958
+ },
1959
+ dry: {
1960
+ type: "boolean",
1961
+ default: false
1962
+ },
1963
+ check: {
1964
+ type: "boolean",
1965
+ default: false
1966
+ },
1967
+ "no-prompt": {
1968
+ type: "boolean",
1969
+ default: false
1970
+ },
1971
+ timeout: {
1972
+ type: "string",
1973
+ default: "5"
1974
+ }
1975
+ }, true);
1976
+ const cfg = await loadConfig(a.path("input"), a.str("site-env"));
1977
+ const explicitDomain = a.positionals[0];
1978
+ const dry = a.flag("dry");
1979
+ const check = a.flag("check");
1980
+ const { candidates, dynamicNotes } = explicitDomain ? {
1981
+ candidates: [{
1982
+ address: `noreply@${explicitDomain}`,
1983
+ domain: explicitDomain,
1984
+ source: "config"
1985
+ }],
1986
+ dynamicNotes: []
1987
+ } : gatherCandidates(cfg, a.path("src"));
1988
+ 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>");
1989
+ const cfToken = loadCloudflareToken();
1990
+ if (!cfToken && !dry) fail(CLOUDFLARE_TOKEN_HINT);
1991
+ const { states, failures } = await observeZones(cfToken, [...new Set(candidates.map((c) => c.domain))]);
1992
+ const emailPlan = planSendingDomains({
1993
+ candidates,
1994
+ provider: "cloudflare"
1995
+ }, states);
1996
+ if (dry || check) {
1997
+ console.log(formatPlan$5(emailPlan));
1998
+ for (const n of dynamicNotes) console.log(n);
1999
+ for (const f of failures) console.error(`! ${f}`);
2000
+ if (check && hasDrift$2(emailPlan)) {
2001
+ console.error("\n✖ a from domain is not fully onboarded");
2002
+ process.exit(1);
2003
+ }
2004
+ return;
2005
+ }
2006
+ const interactive = !a.flag("no-prompt") && Boolean(process.stdin.isTTY && process.stdout.isTTY);
2007
+ const timeoutMs = Number(a.str("timeout")) * 1e3;
2008
+ const results = [];
2009
+ let failed = false;
2010
+ for (const d of emailPlan.domains) {
2011
+ if (d.onboard.action === "skip") {
2012
+ results.push({
2013
+ domain: d.domain,
2014
+ onboard: "present",
2015
+ reminders: []
2016
+ });
2017
+ continue;
2018
+ }
2019
+ if (d.onboard.action === "blocked") {
2020
+ console.error(`? ${d.domain}: ${d.onboard.detail}`);
2021
+ results.push({
2022
+ domain: d.domain,
2023
+ onboard: "blocked",
2024
+ reminders: []
2025
+ });
2026
+ failed = true;
2027
+ continue;
2028
+ }
2029
+ console.log(`\n${d.domain}: ${d.onboard.detail}`);
2030
+ if (interactive && !await confirm(`Enable Email Sending for "${d.domain}"?`)) {
2031
+ results.push({
2032
+ domain: d.domain,
2033
+ onboard: "blocked",
2034
+ reminders: ["skipped by operator"]
2035
+ });
2036
+ continue;
2037
+ }
2038
+ const zone = await findZoneForHost(cfToken, d.domain);
2039
+ if (!zone.ok || !zone.zoneId) {
2040
+ console.error(`✖ ${d.domain}: ${zone.message}`);
2041
+ results.push({
2042
+ domain: d.domain,
2043
+ onboard: "failed",
2044
+ reminders: []
2045
+ });
2046
+ failed = true;
2047
+ continue;
2048
+ }
2049
+ const enabled = await enableSending(cfToken, zone.zoneId, d.domain);
2050
+ if (!enabled.ok) {
2051
+ console.error(`✖ ${d.domain}: ${enabled.message}`);
2052
+ results.push({
2053
+ domain: d.domain,
2054
+ onboard: "failed",
2055
+ reminders: []
2056
+ });
2057
+ failed = true;
2058
+ continue;
2059
+ }
2060
+ console.log(`✓ ${d.domain}: enabled`);
2061
+ const reminders = [];
2062
+ if (enabled.tag) {
2063
+ const dns = await sendingDns(cfToken, zone.zoneId, enabled.tag);
2064
+ const expected = dns.records.filter((r) => r.type === "TXT");
2065
+ if (dns.ok && expected.length) {
2066
+ const schedule = backoffSchedule$1();
2067
+ let pending = expected;
2068
+ for (let attempt = 0; attempt < schedule.length && pending.length; attempt++) {
2069
+ await sleep(Math.min(schedule[attempt], timeoutMs));
2070
+ const resolved = /* @__PURE__ */ new Map();
2071
+ for (const name of new Set(pending.map((r) => r.name))) {
2072
+ const r = await resolveTxt(name);
2073
+ if (r.ok) resolved.set(name, r.contents);
2074
+ }
2075
+ pending = dnsStatus(pending, resolved).filter((s) => !s.present).map((s) => s.record);
2076
+ console.log(` DNS: ${expected.length - pending.length}/${expected.length} records resolving (attempt ${attempt + 1}/${schedule.length})`);
2077
+ }
2078
+ if (pending.length) reminders.push(`${pending.length} DNS record(s) not yet resolving — check again shortly.`);
2079
+ }
2080
+ }
2081
+ results.push({
2082
+ domain: d.domain,
2083
+ onboard: "created",
2084
+ reminders
2085
+ });
2086
+ }
2087
+ console.log(`\n${formatSummary$2(results)}`);
2088
+ for (const n of dynamicNotes) console.log(n);
2089
+ for (const f of failures) console.error(`! ${f}`);
2090
+ if (failed) process.exit(1);
2091
+ }
2092
+ /**
2093
+ * The non-blocking deploy guard: warn when a `from` domain isn't onboarded,
2094
+ * exit 0 by default. `--strict` makes it a real gate.
2095
+ *
2096
+ * Runs with no credential too — it then reports "not checked", exits 0, and a
2097
+ * CI job with no CLOUDFLARE_API_TOKEN never fails on the check itself.
2098
+ */
2099
+ async function cmdEmailCheck(argv) {
2100
+ const a = parse$1(argv, {
2101
+ ...SITE_OPTS,
2102
+ src: {
2103
+ type: "string",
2104
+ default: "src"
2105
+ },
2106
+ strict: {
2107
+ type: "boolean",
2108
+ default: false
2109
+ },
2110
+ skip: {
2111
+ type: "boolean",
2112
+ default: false
2113
+ }
2114
+ });
2115
+ if (a.flag("skip") || process.env["VITOPS_SKIP_EMAIL_CHECK"]) {
2116
+ console.log("· vitops email check: skipped (--skip / VITOPS_SKIP_EMAIL_CHECK)");
2117
+ return;
2118
+ }
2119
+ const cfg = await siteConfig(a);
2120
+ const emailCfg = cfg.site.notifications?.email;
2121
+ if (typeof emailCfg === "object" && emailCfg.verifySender === false) {
2122
+ console.log("· vitops email check: skipped (site.notifications.email.verifySender is false)");
2123
+ return;
2124
+ }
2125
+ const { candidates, dynamicNotes } = gatherCandidates(cfg, a.path("src"));
2126
+ if (candidates.length === 0) {
2127
+ console.log("· no from domain configured — nothing to check");
2128
+ return;
2129
+ }
2130
+ const cfToken = loadCloudflareToken();
2131
+ if (!cfToken) {
2132
+ console.log("· vitops email check: not checked — no CLOUDFLARE_API_TOKEN");
2133
+ return;
2134
+ }
2135
+ const { states, failures } = await observeZones(cfToken, [...new Set(candidates.map((c) => c.domain))]);
2136
+ const emailPlan = planSendingDomains({
2137
+ candidates,
2138
+ provider: "cloudflare"
2139
+ }, states);
2140
+ for (const n of dynamicNotes) console.log(n);
2141
+ for (const f of failures) console.error(`! ${f}`);
2142
+ const blocked = blockedDomains(emailPlan);
2143
+ if (blocked.length === 0) {
2144
+ console.log(`✓ every from domain is onboarded`);
2145
+ return;
2146
+ }
2147
+ for (const b of blocked) console.error(`! ${b.domain}: ${b.onboard.detail}`);
2148
+ if (a.flag("strict")) {
2149
+ console.error("\n✖ a from domain is not onboarded (--strict)");
2150
+ process.exit(1);
2151
+ }
2152
+ }
2153
+ async function cmdEmail(argv) {
2154
+ const [sub, ...rest] = argv;
2155
+ switch (sub) {
2156
+ case "setup": return cmdEmailSetup(rest);
2157
+ case "check": return cmdEmailCheck(rest);
2158
+ case void 0:
2159
+ case "-h":
2160
+ case "--help":
2161
+ console.log(EMAIL_HELP);
2162
+ return;
2163
+ default: fail(`unknown "email" subcommand "${sub}" (expected: setup | check). Try: vitops --help`);
2164
+ }
2165
+ }
2166
+
2167
+ // src/commands/setup.ts
1556
2168
  /**
1557
2169
  * `vitops setup` — the scripted path from an empty scaffold to a live site.
1558
2170
  * Subcommands: `repo | hosting | domain | search | ads | all`.
@@ -1659,7 +2271,7 @@ async function cmdSetupRepo(argv) {
1659
2271
  const dry = a.flag("dry");
1660
2272
  const check = a.flag("check");
1661
2273
  if (dry || check) {
1662
- console.log(formatPlan$5(p));
2274
+ console.log(formatPlan$6(p));
1663
2275
  if (check && (p.repo.action !== "skip" || p.branches.action !== "skip" || p.defaultBranch.action !== "skip")) {
1664
2276
  console.error("\n✖ repo/branch setup is not complete");
1665
2277
  process.exit(1);
@@ -1743,7 +2355,7 @@ async function cmdSetupRepo(argv) {
1743
2355
  status: "blocked",
1744
2356
  detail: "no GitHub repo yet — run this again once \"repo\" succeeds"
1745
2357
  });
1746
- console.log(formatSummary$2(results));
2358
+ console.log(formatSummary$3(results));
1747
2359
  if (results.some((r) => r.status === "failed")) process.exit(1);
1748
2360
  }
1749
2361
  async function cmdSetupHosting(argv) {
@@ -1784,7 +2396,7 @@ async function cmdSetupHosting(argv) {
1784
2396
  }
1785
2397
  if (!state.wranglerAuthed && !dry) fail("wrangler is not logged in — run `npx wrangler login` first");
1786
2398
  if (dry || check) {
1787
- console.log(formatPlan$5(p));
2399
+ console.log(formatPlan$6(p));
1788
2400
  if (check && (p.d1.action !== "skip" || p.r2.action !== "skip")) {
1789
2401
  console.error("\n✖ hosting resources are not fully provisioned");
1790
2402
  process.exit(1);
@@ -1853,7 +2465,7 @@ async function cmdSetupHosting(argv) {
1853
2465
  status: "blocked",
1854
2466
  detail: p.secrets["CLOUDFLARE_API_TOKEN"]?.detail ?? "create it by hand"
1855
2467
  });
1856
- console.log(formatSummary$2(results));
2468
+ console.log(formatSummary$3(results));
1857
2469
  for (const n of p.notes) console.log(`! ${n}`);
1858
2470
  if (results.some((r) => r.status === "failed")) process.exit(1);
1859
2471
  }
@@ -1877,13 +2489,14 @@ async function cmdSetupDomain(argv) {
1877
2489
  const customDomain = customDomainMatch?.[1] ?? (a.opt("domain") ? void 0 : void 0);
1878
2490
  const cfg = await loadConfig(a.path("input"), a.str("site-env"));
1879
2491
  const env = cfg.site.environments[a.str("site-env")] ?? cfg.site.environments["production"];
1880
- const step = planDomainConsistency({
2492
+ const state = {
1881
2493
  wranglerDev: !raw.includes("\"routes\"") || Boolean(customDomainMatch) === false,
1882
2494
  ...customDomain ? { customDomain } : {},
1883
2495
  ...env?.url ? { configuredUrl: env.url } : {},
1884
2496
  ...cfg.site.domains?.canonical ? { canonical: cfg.site.domains.canonical } : {},
1885
2497
  ...env?.robots ? { robots: env.robots } : {}
1886
- });
2498
+ };
2499
+ const step = planDomainConsistency(state);
1887
2500
  console.log(`${step.action === "skip" ? "·" : "?"} domain consistency ${step.detail}`);
1888
2501
  if (a.opt("domain")) console.log(`\nCutover is never applied by this command — see SETUP.html stage 4b. To cut over: uncomment routes in wrangler.jsonc with "pattern": "${a.opt("domain")}", deploy, then update site.domains.canonical and site.environments.${a.str("site-env")}.url to match.`);
1889
2502
  if (a.flag("check") && step.action !== "skip") process.exit(1);
@@ -1966,8 +2579,7 @@ async function cmdSetup(argv) {
1966
2579
  }
1967
2580
  }
1968
2581
 
1969
- //#endregion
1970
- //#region src/agents.ts
2582
+ // src/agents.ts
1971
2583
  /**
1972
2584
  * Side-effect-free helpers for the `vitops agents` / `vitops docs` commands.
1973
2585
  * Kept out of cli.ts (which runs main() on import) so they are unit-testable.
@@ -2032,6 +2644,11 @@ const TOPICS = {
2032
2644
  path: "concepts/legal.md",
2033
2645
  summary: "privacy policy / terms / cookie notice derived from config facts"
2034
2646
  },
2647
+ blocks: {
2648
+ path: "",
2649
+ external: "emdash",
2650
+ summary: "EmDash Portable Text blocks and every field (needs @getvitops/emdash installed)"
2651
+ },
2035
2652
  elements: {
2036
2653
  path: "bricks/elements.md",
2037
2654
  summary: "Bricks Builder custom elements, controls, defaults"
@@ -2083,8 +2700,7 @@ function linkSkill(linkPath, targetDir) {
2083
2700
  }
2084
2701
  }
2085
2702
 
2086
- //#endregion
2087
- //#region src/css-scan.ts
2703
+ // src/css-scan.ts
2088
2704
  /** Strip comments, preserving newlines so line numbers survive. */
2089
2705
  function stripComments(css) {
2090
2706
  return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
@@ -2186,8 +2802,7 @@ function extractStyleBlocks(src) {
2186
2802
  return out;
2187
2803
  }
2188
2804
 
2189
- //#endregion
2190
- //#region src/lint-css.ts
2805
+ // src/lint-css.ts
2191
2806
  /**
2192
2807
  * The framework's own track widths. Declared in two places today
2193
2808
  * (`core/css/layout.css` and the tailwind emitter), so a rule keyed to the token
@@ -2402,8 +3017,7 @@ function lintCss(files, format) {
2402
3017
  return findings;
2403
3018
  }
2404
3019
 
2405
- //#endregion
2406
- //#region src/lint-markup.ts
3020
+ // src/lint-markup.ts
2407
3021
  /**
2408
3022
  * The third linting direction: markup that lays out a repeated set of things
2409
3023
  * without the framework's pattern for it.
@@ -2437,7 +3051,7 @@ function lintCss(files, format) {
2437
3051
  * A consumer's markdown content can hold real markup, so this does give up a
2438
3052
  * little coverage; that is the right side to err on for a heuristic.
2439
3053
  */
2440
- const MARKUP_EXT = new Set([
3054
+ const MARKUP_EXT = /* @__PURE__ */ new Set([
2441
3055
  ".astro",
2442
3056
  ".html",
2443
3057
  ".htm",
@@ -2625,8 +3239,7 @@ function lintMarkup(files) {
2625
3239
  return findings;
2626
3240
  }
2627
3241
 
2628
- //#endregion
2629
- //#region src/lint-tokens.ts
3242
+ // src/lint-tokens.ts
2630
3243
  /**
2631
3244
  * Rewrite every old name in one pass.
2632
3245
  *
@@ -2682,8 +3295,7 @@ function lintTokens(files, renames) {
2682
3295
  return findings;
2683
3296
  }
2684
3297
 
2685
- //#endregion
2686
- //#region src/lint.ts
3298
+ // src/lint.ts
2687
3299
  /**
2688
3300
  * `vitops lint` — find framework classes in consumer source that resolve to
2689
3301
  * nothing in the format being built.
@@ -2866,8 +3478,7 @@ function lintSource(files, v, format) {
2866
3478
  return findings;
2867
3479
  }
2868
3480
 
2869
- //#endregion
2870
- //#region src/cli.ts
3481
+ // src/cli.ts
2871
3482
  /**
2872
3483
  * `vitops` — CLI for the Vitops design-system generator.
2873
3484
  *
@@ -2884,6 +3495,7 @@ function lintSource(files, v, format) {
2884
3495
  * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
2885
3496
  * vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
2886
3497
  * vitops setup repo | hosting | domain | search | ads | all [--dry] [--check]
3498
+ * vitops email setup [domain] [--dry] [--check] | check [--strict] [--skip]
2887
3499
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
2888
3500
  *
2889
3501
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
@@ -2893,23 +3505,23 @@ function lintSource(files, v, format) {
2893
3505
  * command groups big enough, and self-contained enough, to earn their own file.
2894
3506
  * Everything else stays here.
2895
3507
  */
2896
- const FORMATS = new Set([
3508
+ const FORMATS = /* @__PURE__ */ new Set([
2897
3509
  "bricks",
2898
3510
  "css",
2899
3511
  "tailwind",
2900
3512
  "design"
2901
3513
  ]);
2902
- const LINT_FORMATS = new Set([
3514
+ const LINT_FORMATS = /* @__PURE__ */ new Set([
2903
3515
  "bricks",
2904
3516
  "css",
2905
3517
  "tailwind"
2906
3518
  ]);
2907
- const LEGAL_DOCS = new Set([
3519
+ const LEGAL_DOCS = /* @__PURE__ */ new Set([
2908
3520
  "privacy",
2909
3521
  "terms",
2910
3522
  "cookies"
2911
3523
  ]);
2912
- const LEGAL_OUTPUTS = new Set([
3524
+ const LEGAL_OUTPUTS = /* @__PURE__ */ new Set([
2913
3525
  "md",
2914
3526
  "html",
2915
3527
  "portable-text"
@@ -3098,7 +3710,7 @@ async function cmdFavicon(argv) {
3098
3710
  fail(msg(err));
3099
3711
  }
3100
3712
  }
3101
- const OUTPUT_KINDS = new Set([
3713
+ const OUTPUT_KINDS = /* @__PURE__ */ new Set([
3102
3714
  "webm",
3103
3715
  "mp4",
3104
3716
  "poster"
@@ -3395,7 +4007,8 @@ async function cmdRobots(argv) {
3395
4007
  }
3396
4008
  });
3397
4009
  const siteEnv = a.str("site-env");
3398
- const content = renderRobotsTxt(await loadConfig(a.path("input"), siteEnv), { env: siteEnv });
4010
+ const site = await loadConfig(a.path("input"), siteEnv);
4011
+ const content = renderRobotsTxt(site, { env: siteEnv });
3399
4012
  if (!a.opt("out")) {
3400
4013
  process.stdout.write(content);
3401
4014
  return;
@@ -3527,12 +4140,13 @@ function cliVersion() {
3527
4140
  }
3528
4141
  }
3529
4142
  function generatorAssetsDir() {
3530
- return join(dirname(createRequire(import.meta.url).resolve("@getvitops/generator/package.json")), "assets");
4143
+ const pkg = createRequire(import.meta.url).resolve("@getvitops/generator/package.json");
4144
+ return join(dirname(pkg), "assets");
3531
4145
  }
3532
4146
  const AGENTS_START = "<!-- vitops:start -->";
3533
4147
  const AGENTS_END = "<!-- vitops:end -->";
3534
4148
  /** Print a live reference doc (rendered from the project's config) to stdout. */
3535
- function cmdDocs(argv) {
4149
+ async function cmdDocs(argv) {
3536
4150
  const { values, positionals } = parseArgs({
3537
4151
  args: argv,
3538
4152
  options: {
@@ -3556,9 +4170,31 @@ function cmdDocs(argv) {
3556
4170
  return;
3557
4171
  }
3558
4172
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
3559
- const docs = generateDocs(loadDesignSystem(resolve(values.input), values.theme), generatorAssetsDir());
3560
- const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
3561
- process.stdout.write(paths.map((p) => docs[p]).join("\n"));
4173
+ const names = values.all ? Object.keys(TOPICS) : [topic];
4174
+ const docs = names.every((n) => TOPICS[n].external) ? {} : generateDocs(loadDesignSystem(resolve(values.input), values.theme), generatorAssetsDir());
4175
+ const parts = [];
4176
+ for (const name of names) {
4177
+ const t = TOPICS[name];
4178
+ if (!t.external) {
4179
+ parts.push(docs[t.path]);
4180
+ continue;
4181
+ }
4182
+ const doc = await externalDoc(t.external);
4183
+ if (doc) parts.push(doc);
4184
+ else if (!values.all) fail(`topic "${name}" needs @getvitops/${t.external} installed in this project`);
4185
+ }
4186
+ process.stdout.write(parts.join("\n"));
4187
+ }
4188
+ /** Render a topic owned by an optional package, resolved from the project, not from the CLI. */
4189
+ async function externalDoc(pkg) {
4190
+ try {
4191
+ const manifest = createRequire(join(process.cwd(), "package.json")).resolve(`@getvitops/${pkg}/package.json`);
4192
+ const exp = JSON.parse(readFileSync(manifest, "utf8")).exports["."].import;
4193
+ const entry = join(dirname(manifest), exp);
4194
+ return (await import(pathToFileURL(entry).href)).renderBlocksDoc?.() ?? null;
4195
+ } catch {
4196
+ return null;
4197
+ }
3562
4198
  }
3563
4199
  function cmdLint(argv) {
3564
4200
  const { values, positionals } = parseArgs({
@@ -3603,7 +4239,7 @@ function cmdLint(argv) {
3603
4239
  "icon",
3604
4240
  "border"
3605
4241
  ]).map((u) => u.cls);
3606
- const LINTABLE = new Set([
4242
+ const LINTABLE = /* @__PURE__ */ new Set([
3607
4243
  ".astro",
3608
4244
  ".html",
3609
4245
  ".js",
@@ -3836,6 +4472,8 @@ async function main() {
3836
4472
  case "icons": return cmdIcons(rest);
3837
4473
  case "search": return cmdSearch(rest);
3838
4474
  case "ads": return cmdAds(rest);
4475
+ case "crm": return cmdCrm(rest);
4476
+ case "email": return cmdEmail(rest);
3839
4477
  case "setup": return cmdSetup(rest);
3840
4478
  case "media": return cmdMedia(rest);
3841
4479
  default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
@@ -3843,6 +4481,5 @@ async function main() {
3843
4481
  }
3844
4482
  main().catch((err) => fail(msg(err)));
3845
4483
 
3846
- //#endregion
3847
4484
  export { };
3848
4485
  //# sourceMappingURL=cli.mjs.map