@getvitops/cli 8.2.0 → 8.3.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
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
4
  import { pathToFileURL } from "node:url";
5
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, extname, join, parse, relative, resolve } from "node:path";
7
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";
8
8
  import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
@@ -14,11 +14,14 @@ 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, 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 { DEFAULT_BUDGETS, buildReport, formatSummary as formatSummary$2, parseImpacts, pathsFromSeed, planPages, reportFiles, runAudit, runCommand, startPreview } from "@getvitops/utils/audit";
17
18
  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 { backoffSchedule as backoffSchedule$1, blockedDomains, collectFromAddresses, dnsStatus, enableSending, findZoneForHost, formatPlan as formatPlan$5, formatSummary as formatSummary$3, hasDrift as hasDrift$2, listSendingSubdomains, planSendingDomains, resolveTxt, sendingDns } from "@getvitops/utils/email";
19
20
  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";
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import { createServer } from "node:net";
23
+ import { adminCollaboratorIds, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan as formatPlan$6, formatSummary as formatSummary$4, ghAuthed, mainProtected, originRemote, plan as plan$4, planDomainConsistency, protectMain, pushBranches, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami } from "@getvitops/utils/hosting";
24
+ import { createWidget, deriveDomains, formatPlan as formatPlan$7, formatSummary as formatSummary$5, hasDrift as hasDrift$3, listWidgets, patchSiteKey, plan as plan$5, resolveAccountId } from "@getvitops/utils/turnstile";
22
25
 
23
26
  // src/args.ts
24
27
  /**
@@ -360,6 +363,7 @@ Usage:
360
363
  vitops setup <sub> [opts] Scripted onboarding: repo, hosting, domain, search, ads, all
361
364
  vitops email <sub> [opts] Sender-domain onboarding: check (setup) + deploy guard (check)
362
365
  vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
366
+ vitops audit [options] Performance + accessibility budgets over a built site (Lighthouse + axe)
363
367
 
364
368
  Generate options:
365
369
  -i, --input <path> design-system.json or site config (default: ./design-system.json)
@@ -415,7 +419,8 @@ Agents options:
415
419
  Docs options:
416
420
  <topic> classes | authoring | formats | color | scales | patterns | elements
417
421
  (no topic: list topics with summaries)
418
- -i, --input <path> design-system.json or site config (default: ./design-system.json)
422
+ -i, --input <path> site config or design-system.json (default: ./site.json, else
423
+ ./design-system.json)
419
424
  --theme <name> Theme to render docs from, when --input is a site config
420
425
  --all Print every topic, concatenated
421
426
 
@@ -428,7 +433,8 @@ Icons options:
428
433
  --json Machine-readable report on stdout
429
434
 
430
435
  Lint options:
431
- -i, --input <path> design-system.json or site config (default: ./design-system.json)
436
+ -i, --input <path> site config or design-system.json (default: ./site.json, else
437
+ ./design-system.json)
432
438
  --theme <name> Theme to judge classes against, when --input is a site config
433
439
  -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
434
440
  Responsive md-* classes are real in css/bricks, inert in tailwind.
@@ -671,6 +677,22 @@ Setup domain options:
671
677
  or deploys. Cutover overwrites the zone's DNS record and can take an existing live
672
678
  site down; that step is manual, on purpose. See SETUP.html's "Domain strategy" stage.
673
679
 
680
+ Setup turnstile options:
681
+ -i, --input <path> Site config (default: ./site.json)
682
+ --site-env <env> Environment to read (default: production)
683
+ --dry Print the plan and exit (needs no token)
684
+ --check Exit non-zero if no widget/site key is set up; mutates nothing
685
+ --no-write Do not write site.security.turnstile.siteKey back to the config
686
+ --no-secret Do not run \`wrangler secret put\` for the new widget's secret
687
+
688
+ Creates a managed Turnstile widget for the site's hostnames (canonical + each
689
+ environment URL) unless one already covers them, and writes the PUBLIC site key to
690
+ site.security.turnstile.siteKey. The SECRET is returned once at creation: it is piped
691
+ to \`wrangler secret put VITOPS_TURNSTILE_SECRET\` and never written to a config file.
692
+ Never edits, rotates or deletes an existing widget.
693
+ Needs CLOUDFLARE_API_TOKEN with "Account > Turnstile > Edit", plus CLOUDFLARE_ACCOUNT_ID
694
+ (or a token that can list accounts), and \`wrangler login\` for the secret.
695
+
674
696
  Setup search options:
675
697
  Forwards every option to \`vitops search setup\` unchanged — see "Search setup
676
698
  options:" above.
@@ -689,6 +711,19 @@ Setup all options:
689
711
  Runs repo, hosting, search, ads in order. Stops at the first stage that fails;
690
712
  a blocked step (the API token, GitHub Pro/Team gating) is reported, not fatal.
691
713
 
714
+ Emdash subcommands:
715
+ vitops emdash prepare-db Migrate and seed the local D1 the build prerenders from
716
+ vitops emdash check-prerender [d] Fail if a prerendered page is not a real page
717
+
718
+ Emdash prepare-db options:
719
+ (none) Starts \`astro dev\` once, calls EmDash's dev-bypass setup (migrates, applies the
720
+ seed's sample content), checks the home page renders, stops. Only writes the
721
+ disposable .wrangler/state. SKIP_PREPARE_LOCAL_DB=1 skips it.
722
+
723
+ Emdash check-prerender options:
724
+ [dir] Build output to check (default: dist/client). Fails on a page that carries the
725
+ pending-migrations message, is not an HTML document, or has no <title>.
726
+
692
727
  Email subcommands:
693
728
  vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
694
729
  vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
@@ -754,6 +789,29 @@ Media options:
754
789
  ffmpeg output is not reproducible across versions, so re-encoding in CI churns
755
790
  the diff on every toolchain bump. Use --force when you mean to re-encode.
756
791
 
792
+ Audit options:
793
+ --build Run \`astro build\` first (default: audit the existing build)
794
+ --url <base> Audit an already-running server instead of \`astro preview\`
795
+ --port <n> Port for \`astro preview\` (default: 4398; aborts if taken)
796
+ --dir <path> Project directory (default: .)
797
+ --pages <list> Extra comma-separated paths. Always tried: / /privacy /terms
798
+ /cookies /contact, plus links in seed/seed.json's primary* and
799
+ footer* menus. A 404 skips a page; a 404 on / fails the run.
800
+ --no-defaults Audit only / and --pages
801
+ --seed <path> EmDash seed to read menus from (default: seed/seed.json if present)
802
+ --lcp <ms> LCP budget (default: 2500)
803
+ --cls <n> CLS budget (default: 0.1)
804
+ --tbt <ms> Total Blocking Time budget, the INP proxy (default: 200)
805
+ --impact <list> axe impacts that fail a page (default: serious,critical)
806
+ --form-factor <f> mobile | desktop Lighthouse profile (default: mobile)
807
+ -o, --out <dir> Report directory (default: .vitops/audit): report.json, report.md,
808
+ and pages/<page>.{json,md}
809
+ --warn Report without failing (exit 0), for first adoption
810
+
811
+ Needs the optional packages: npm i -D playwright @axe-core/playwright lighthouse
812
+ && npx playwright install chromium. A pass means no automated axe violations and the
813
+ budgets held — it is NOT AODA certification; report.md lists the manual checks.
814
+
757
815
  Common:
758
816
  -h, --help Show this help
759
817
  `;
@@ -784,6 +842,15 @@ const EMAIL_HELP = `vitops email — sender-domain onboarding checks
784
842
  vitops email setup [domain] Onboard a from-address domain, waiting for its DNS to resolve
785
843
  vitops email check [opts] Non-blocking deploy guard: warn if a from domain isn't onboarded
786
844
 
845
+ Run \`vitops --help\` for the full option list for each subcommand.
846
+ `;
847
+ const EMDASH_HELP = `vitops emdash — build-time steps for an EmDash site
848
+
849
+ vitops emdash prepare-db Migrate and seed the local D1 the build prerenders from
850
+ (SKIP_PREPARE_LOCAL_DB=1 skips)
851
+ vitops emdash check-prerender [d] Fail if a prerendered page under [d] (default dist/client)
852
+ is not a real page
853
+
787
854
  Run \`vitops --help\` for the full option list for each subcommand.
788
855
  `;
789
856
  const SETUP_HELP$1 = `vitops setup — the scripted path from an empty scaffold to a live site
@@ -820,7 +887,9 @@ const COMMANDS = [
820
887
  "crm",
821
888
  "setup",
822
889
  "email",
823
- "media"
890
+ "emdash",
891
+ "media",
892
+ "audit"
824
893
  ];
825
894
  /**
826
895
  * The slice of `HELP` documenting one command, addressed by its heading.
@@ -849,7 +918,7 @@ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
849
918
  /** The help text to print for `vitops <command> [sub] --help`. */
850
919
  function helpFor(command, argv) {
851
920
  const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
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";
921
+ 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" : command === "emdash" ? "vitops emdash — build-time steps for an EmDash site\n\n vitops emdash prepare-db Migrate and seed the local D1 the build prerenders from\n (SKIP_PREPARE_LOCAL_DB=1 skips)\n vitops emdash check-prerender [d] Fail if a prerendered page under [d] (default dist/client)\n is not a real page\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 vitops audit [options] Performance + accessibility budgets over a built site (Lighthouse + axe)\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> site config or design-system.json (default: ./site.json, else\n ./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> site config or design-system.json (default: ./site.json, else\n ./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 turnstile options:\n -i, --input <path> Site config (default: ./site.json)\n --site-env <env> Environment to read (default: production)\n --dry Print the plan and exit (needs no token)\n --check Exit non-zero if no widget/site key is set up; mutates nothing\n --no-write Do not write site.security.turnstile.siteKey back to the config\n --no-secret Do not run `wrangler secret put` for the new widget's secret\n\n Creates a managed Turnstile widget for the site's hostnames (canonical + each\n environment URL) unless one already covers them, and writes the PUBLIC site key to\n site.security.turnstile.siteKey. The SECRET is returned once at creation: it is piped\n to `wrangler secret put VITOPS_TURNSTILE_SECRET` and never written to a config file.\n Never edits, rotates or deletes an existing widget.\n Needs CLOUDFLARE_API_TOKEN with \"Account > Turnstile > Edit\", plus CLOUDFLARE_ACCOUNT_ID\n (or a token that can list accounts), and `wrangler login` for the secret.\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\nEmdash subcommands:\n vitops emdash prepare-db Migrate and seed the local D1 the build prerenders from\n vitops emdash check-prerender [d] Fail if a prerendered page is not a real page\n\nEmdash prepare-db options:\n (none) Starts `astro dev` once, calls EmDash's dev-bypass setup (migrates, applies the\n seed's sample content), checks the home page renders, stops. Only writes the\n disposable .wrangler/state. SKIP_PREPARE_LOCAL_DB=1 skips it.\n\nEmdash check-prerender options:\n [dir] Build output to check (default: dist/client). Fails on a page that carries the\n pending-migrations message, is not an HTML document, or has no <title>.\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\nAudit options:\n --build Run `astro build` first (default: audit the existing build)\n --url <base> Audit an already-running server instead of `astro preview`\n --port <n> Port for `astro preview` (default: 4398; aborts if taken)\n --dir <path> Project directory (default: .)\n --pages <list> Extra comma-separated paths. Always tried: / /privacy /terms\n /cookies /contact, plus links in seed/seed.json's primary* and\n footer* menus. A 404 skips a page; a 404 on / fails the run.\n --no-defaults Audit only / and --pages\n --seed <path> EmDash seed to read menus from (default: seed/seed.json if present)\n --lcp <ms> LCP budget (default: 2500)\n --cls <n> CLS budget (default: 0.1)\n --tbt <ms> Total Blocking Time budget, the INP proxy (default: 200)\n --impact <list> axe impacts that fail a page (default: serious,critical)\n --form-factor <f> mobile | desktop Lighthouse profile (default: mobile)\n -o, --out <dir> Report directory (default: .vitops/audit): report.json, report.md,\n and pages/<page>.{json,md}\n --warn Report without failing (exit 0), for first adoption\n\n Needs the optional packages: npm i -D playwright @axe-core/playwright lighthouse\n && npx playwright install chromium. A pass means no automated axe violations and the\n budgets held — it is NOT AODA certification; report.md lists the manual checks.\n\nCommon:\n -h, --help Show this help\n";
853
922
  }
854
923
 
855
924
  // src/commands/search.ts
@@ -1666,6 +1735,147 @@ async function cmdAds(argv) {
1666
1735
  }
1667
1736
  }
1668
1737
 
1738
+ // src/commands/audit.ts
1739
+ /**
1740
+ * `vitops audit` — performance + accessibility budgets over a built site.
1741
+ *
1742
+ * The planner (page discovery, status handling, budgets, report rendering) is pure and
1743
+ * lives in `@getvitops/utils/audit` beside `ads/`, `indexing/` and `onboarding/`, as
1744
+ * `plan.ts`; this file is wiring only — flags in, files + exit code out. The browser
1745
+ * tooling (Playwright, axe, Lighthouse) is lazy and optional, see `audit/run.ts`.
1746
+ */
1747
+ /** Dedicated port: not Astro's 4321, which a developer's own `astro dev` is likely on. */
1748
+ const DEFAULT_PORT = 4398;
1749
+ function num(flag, raw, fallback) {
1750
+ if (raw === void 0) return fallback;
1751
+ const n = Number(raw);
1752
+ if (!Number.isFinite(n) || n <= 0) fail(`audit: --${flag} must be a positive number`);
1753
+ return n;
1754
+ }
1755
+ async function cmdAudit(argv) {
1756
+ const args = parse$1(argv, {
1757
+ dir: {
1758
+ type: "string",
1759
+ default: "."
1760
+ },
1761
+ url: { type: "string" },
1762
+ build: {
1763
+ type: "boolean",
1764
+ default: false
1765
+ },
1766
+ port: { type: "string" },
1767
+ pages: { type: "string" },
1768
+ "no-defaults": {
1769
+ type: "boolean",
1770
+ default: false
1771
+ },
1772
+ seed: { type: "string" },
1773
+ out: {
1774
+ type: "string",
1775
+ short: "o",
1776
+ default: ".vitops/audit"
1777
+ },
1778
+ lcp: { type: "string" },
1779
+ cls: { type: "string" },
1780
+ tbt: { type: "string" },
1781
+ impact: { type: "string" },
1782
+ "form-factor": {
1783
+ type: "string",
1784
+ default: "mobile"
1785
+ },
1786
+ warn: {
1787
+ type: "boolean",
1788
+ default: false
1789
+ }
1790
+ });
1791
+ const dir = resolve(args.str("dir"));
1792
+ const formFactor = args.str("form-factor");
1793
+ if (formFactor !== "mobile" && formFactor !== "desktop") fail("audit: --form-factor must be mobile or desktop");
1794
+ let impacts = DEFAULT_BUDGETS.blockingImpacts;
1795
+ const impactFlag = args.opt("impact");
1796
+ if (impactFlag) try {
1797
+ impacts = parseImpacts(impactFlag);
1798
+ } catch (err) {
1799
+ fail(`audit: ${msg(err)}`);
1800
+ }
1801
+ const budgets = {
1802
+ lcpMs: num("lcp", args.opt("lcp"), DEFAULT_BUDGETS.lcpMs),
1803
+ cls: num("cls", args.opt("cls"), DEFAULT_BUDGETS.cls),
1804
+ tbtMs: num("tbt", args.opt("tbt"), DEFAULT_BUDGETS.tbtMs),
1805
+ blockingImpacts: impacts
1806
+ };
1807
+ const seedFlag = args.opt("seed");
1808
+ const seedPath = seedFlag ? resolve(seedFlag) : join(dir, "seed", "seed.json");
1809
+ let menuPaths = [];
1810
+ if (existsSync(seedPath)) try {
1811
+ menuPaths = pathsFromSeed(JSON.parse(readFileSync(seedPath, "utf8")));
1812
+ } catch (err) {
1813
+ fail(`audit: could not read menus from ${seedPath}: ${msg(err)}`);
1814
+ }
1815
+ else if (seedFlag) fail(`audit: seed not found: ${seedPath}`);
1816
+ const paths = planPages({
1817
+ menuPaths,
1818
+ extra: (args.opt("pages") ?? "").split(",").map((s) => s.trim()).filter(Boolean),
1819
+ includeDefaults: !args.flag("no-defaults")
1820
+ });
1821
+ if (args.flag("build") && !args.opt("url")) {
1822
+ console.log("▸ building (astro build)");
1823
+ try {
1824
+ await runCommand("npx", [
1825
+ "--no-install",
1826
+ "astro",
1827
+ "build"
1828
+ ], dir);
1829
+ } catch (err) {
1830
+ fail(`audit: build failed: ${msg(err)}`);
1831
+ }
1832
+ }
1833
+ let server;
1834
+ let baseUrl = args.opt("url");
1835
+ try {
1836
+ if (!baseUrl) {
1837
+ const port = num("port", args.opt("port"), DEFAULT_PORT);
1838
+ console.log(`▸ astro preview on :${port}`);
1839
+ server = await startPreview({
1840
+ cwd: dir,
1841
+ port
1842
+ });
1843
+ baseUrl = server.baseUrl;
1844
+ }
1845
+ console.log(`▸ auditing ${paths.length} candidate page(s) at ${baseUrl} (${formFactor})`);
1846
+ const pages = await runAudit({
1847
+ baseUrl,
1848
+ paths,
1849
+ budgets,
1850
+ formFactor,
1851
+ onProgress: (m) => console.log(` ${m}`)
1852
+ });
1853
+ const report = buildReport({
1854
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1855
+ baseUrl,
1856
+ formFactor,
1857
+ budgets,
1858
+ pages
1859
+ });
1860
+ const out = resolve(dir, args.str("out"));
1861
+ for (const [name, contents] of Object.entries(reportFiles(report))) {
1862
+ const file = join(out, name);
1863
+ mkdirSync(dirname(file), { recursive: true });
1864
+ writeFileSync(file, contents);
1865
+ }
1866
+ console.log(`\n${formatSummary$2(report)}\n\nReport: ${join(out, "report.md")}`);
1867
+ if (!report.passed) {
1868
+ if (args.flag("warn")) console.log("(--warn: reporting only, exit 0)");
1869
+ else process.exitCode = 1;
1870
+ }
1871
+ } catch (err) {
1872
+ server?.stop();
1873
+ fail(`audit: ${msg(err)}`);
1874
+ } finally {
1875
+ server?.stop();
1876
+ }
1877
+ }
1878
+
1669
1879
  // src/commands/crm.ts
1670
1880
  /**
1671
1881
  * `vitops crm` — the two things `site.crm` needs a human-in-the-loop for.
@@ -2084,7 +2294,7 @@ async function cmdEmailSetup(argv) {
2084
2294
  reminders
2085
2295
  });
2086
2296
  }
2087
- console.log(`\n${formatSummary$2(results)}`);
2297
+ console.log(`\n${formatSummary$3(results)}`);
2088
2298
  for (const n of dynamicNotes) console.log(n);
2089
2299
  for (const f of failures) console.error(`! ${f}`);
2090
2300
  if (failed) process.exit(1);
@@ -2164,6 +2374,156 @@ async function cmdEmail(argv) {
2164
2374
  }
2165
2375
  }
2166
2376
 
2377
+ // src/prerender-check.ts
2378
+ /**
2379
+ * The pure half of `vitops emdash check-prerender`: is a prerendered `.html` body a real page?
2380
+ *
2381
+ * `astro build` succeeds even when a prerendered route's response was an error. Against a local
2382
+ * database with pending migrations EmDash answers with a one-line "Database migrations are
2383
+ * required" body, which is written to `index.html` like any other page, and the deploy then
2384
+ * publishes it.
2385
+ */
2386
+ const PENDING_MIGRATIONS = /Database migrations are required/i;
2387
+ /** Why `html` is not a real page, or `undefined` when it looks like one. */
2388
+ function prerenderProblem(html) {
2389
+ if (PENDING_MIGRATIONS.test(html)) return "carries the pending-migrations message";
2390
+ if (!/<html[\s>]/i.test(html)) return "is not an HTML document";
2391
+ if (!/<title>[^<]+<\/title>/i.test(html)) return "has no <title>";
2392
+ }
2393
+
2394
+ // src/commands/emdash.ts
2395
+ /**
2396
+ * `vitops emdash` — build-time steps for an EmDash site on the Cloudflare adapter.
2397
+ *
2398
+ * These used to be copied into every scaffold as `scripts/*.mjs`, which froze each fix at
2399
+ * scaffold time. As commands they arrive with `@getvitops/cli`, so a site's `package.json`
2400
+ * keeps one-line entries and `vp update` delivers the logic (the same move `vitops setup`
2401
+ * made for the template's old `init-github`/`init-hosting` scripts).
2402
+ *
2403
+ * prepare-db migrate and seed the LOCAL D1 the build prerenders from
2404
+ * check-prerender fail if a prerendered page is not a real page
2405
+ *
2406
+ * Why both exist: `astro build` prerenders every page inside a local workerd against a local D1
2407
+ * (`.wrangler/state`), never the deployed database. The template sets
2408
+ * `migrations.runtime: 'check'`, so a build against a local database with pending migrations
2409
+ * prerenders EmDash's one-line "Database migrations are required" response into every page,
2410
+ * and the build still succeeds. A fresh CI runner, a first deploy, a `SKIP_PULL_CONTENT=1`
2411
+ * build and an upgrade deploy all hand the build exactly that database.
2412
+ */
2413
+ function freePort() {
2414
+ return new Promise((resolve, reject) => {
2415
+ const server = createServer();
2416
+ server.once("error", reject);
2417
+ server.listen(0, "127.0.0.1", () => {
2418
+ const { port } = server.address() ?? { port: 0 };
2419
+ server.close(() => resolve(port));
2420
+ });
2421
+ });
2422
+ }
2423
+ async function until(test, what, ms = 12e4) {
2424
+ const deadline = Date.now() + ms;
2425
+ while (Date.now() < deadline) {
2426
+ try {
2427
+ if (await test()) return;
2428
+ } catch {}
2429
+ await new Promise((r) => setTimeout(r, 1e3));
2430
+ }
2431
+ throw new Error(`timed out waiting for ${what}`);
2432
+ }
2433
+ const log = (msg) => console.log(`prepare-db: ${msg}`);
2434
+ /**
2435
+ * `astro dev` daemonizes: the child outlives the command that started it, so capturing its
2436
+ * output through pipes would leave this process waiting on the daemon's inherited handles,
2437
+ * which looks exactly like a hang. Output is ignored and every call has a timeout.
2438
+ */
2439
+ const astro = (args) => spawnSync(existsSync("node_modules/.bin/astro") ? "node_modules/.bin/astro" : "astro", args, {
2440
+ stdio: "ignore",
2441
+ timeout: 3e5,
2442
+ env: {
2443
+ ...process.env,
2444
+ CI: "1"
2445
+ }
2446
+ });
2447
+ /**
2448
+ * Starts `astro dev` once, calls EmDash's `dev-bypass` setup endpoint (the non-interactive
2449
+ * setup wizard: it runs migrations, `dev: 'auto'`, and applies the seed), checks the home page
2450
+ * renders, stops. Only ever writes the local `.wrangler/state`, which is disposable; the dev
2451
+ * administrator it creates lives in that copy and is never deployed. Content already there
2452
+ * (from `pull-content`) is kept: the seed applies with `onConflict: skip`.
2453
+ */
2454
+ async function cmdPrepareDb() {
2455
+ if (process.env.SKIP_PREPARE_LOCAL_DB) {
2456
+ log("SKIP_PREPARE_LOCAL_DB set — leaving the local database as it is.");
2457
+ return;
2458
+ }
2459
+ const port = await freePort();
2460
+ const base = `http://127.0.0.1:${port}`;
2461
+ log(`starting astro dev on ${base} ...`);
2462
+ const started = astro([
2463
+ "dev",
2464
+ "--port",
2465
+ String(port),
2466
+ "--host",
2467
+ "127.0.0.1"
2468
+ ]);
2469
+ if (started.error?.code === "ENOENT") fail("`astro` is not on PATH. Run it through the package scripts (`vpr prepare-local-db`).");
2470
+ if (started.status !== 0) fail(`could not start astro dev (${started.signal ?? `exit ${started.status}`}). It is killed after 5 minutes (exit 143 = SIGTERM). Run \`astro dev\` by hand to see why.`);
2471
+ let failure = null;
2472
+ try {
2473
+ await until(async () => (await fetch(`${base}/_emdash/api/setup/status`)).ok, "astro dev");
2474
+ log("dev server is up; migrating and seeding (first run can take a minute) ...");
2475
+ const setup = await fetch(`${base}/_emdash/api/setup/dev-bypass?redirect=/`, {
2476
+ redirect: "manual",
2477
+ signal: AbortSignal.timeout(12e4)
2478
+ });
2479
+ if (setup.status >= 400) console.warn(`prepare-db: dev-bypass answered ${setup.status}; checking the home page anyway.`);
2480
+ const home = await fetch(`${base}/`);
2481
+ const html = await home.text();
2482
+ if (!home.ok) throw new Error(`astro dev answered ${home.status} on / — the dev runtime is broken`);
2483
+ if (PENDING_MIGRATIONS.test(html)) throw new Error("the local database still has pending migrations");
2484
+ log("the local database is migrated and the home page renders.");
2485
+ } catch (e) {
2486
+ failure = e;
2487
+ } finally {
2488
+ astro(["dev", "stop"]);
2489
+ }
2490
+ if (failure) fail(`${failure.message}\nRefusing to continue: prerendering now would publish error pages as the site.`);
2491
+ }
2492
+ function* htmlFiles(dir) {
2493
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2494
+ const path = join(dir, entry.name);
2495
+ if (entry.isDirectory()) yield* htmlFiles(path);
2496
+ else if (entry.name.endsWith(".html")) yield path;
2497
+ }
2498
+ }
2499
+ function cmdCheckPrerender(argv) {
2500
+ const root = argv.find((a) => !a.startsWith("-")) ?? "dist/client";
2501
+ if (!existsSync(root)) fail(`${root} does not exist (run \`astro build\` first).`);
2502
+ const bad = [];
2503
+ let count = 0;
2504
+ for (const file of htmlFiles(root)) {
2505
+ count += 1;
2506
+ const why = prerenderProblem(readFileSync(file, "utf8"));
2507
+ if (why) bad.push(` ${file} ${why}`);
2508
+ }
2509
+ if (count === 0) fail(`no .html files under ${root}.`);
2510
+ if (bad.length) fail(`the build prerendered pages that are not real pages:\n${bad.join("\n")}\n\nThe local database the build reads is probably unmigrated. Run \`vpr prepare-local-db\` (it migrates and seeds the local D1 through \`astro dev\`), then build again.`);
2511
+ console.log(`check-prerender: ${count} prerendered page(s) look like real pages.`);
2512
+ }
2513
+ async function cmdEmdash(argv) {
2514
+ const [sub, ...rest] = argv;
2515
+ switch (sub) {
2516
+ case "prepare-db": return cmdPrepareDb();
2517
+ case "check-prerender": return cmdCheckPrerender(rest);
2518
+ case void 0:
2519
+ case "-h":
2520
+ case "--help":
2521
+ console.log(EMDASH_HELP);
2522
+ return;
2523
+ default: fail(`unknown "emdash" subcommand "${sub}" (expected: prepare-db | check-prerender). Try: vitops --help`);
2524
+ }
2525
+ }
2526
+
2167
2527
  // src/commands/setup.ts
2168
2528
  /**
2169
2529
  * `vitops setup` — the scripted path from an empty scaffold to a live site.
@@ -2188,6 +2548,7 @@ const SETUP_HELP = `vitops setup <sub> [options]
2188
2548
  vitops setup repo [--dry] [--check] [--public]
2189
2549
  vitops setup hosting [--dry] [--check] [--platform cloudflare|node]
2190
2550
  vitops setup domain [--dry] [--check] [--domain <apex>]
2551
+ vitops setup turnstile [--dry] [--check] [--no-write] [--no-secret]
2191
2552
  vitops setup search [options] (forwards to \`vitops search setup\`)
2192
2553
  vitops setup ads [options] (forwards to \`vitops ads setup\`)
2193
2554
  vitops setup all [--dry] [--check] [--public] [--platform <p>]
@@ -2355,7 +2716,7 @@ async function cmdSetupRepo(argv) {
2355
2716
  status: "blocked",
2356
2717
  detail: "no GitHub repo yet — run this again once \"repo\" succeeds"
2357
2718
  });
2358
- console.log(formatSummary$3(results));
2719
+ console.log(formatSummary$4(results));
2359
2720
  if (results.some((r) => r.status === "failed")) process.exit(1);
2360
2721
  }
2361
2722
  async function cmdSetupHosting(argv) {
@@ -2465,7 +2826,7 @@ async function cmdSetupHosting(argv) {
2465
2826
  status: "blocked",
2466
2827
  detail: p.secrets["CLOUDFLARE_API_TOKEN"]?.detail ?? "create it by hand"
2467
2828
  });
2468
- console.log(formatSummary$3(results));
2829
+ console.log(formatSummary$4(results));
2469
2830
  for (const n of p.notes) console.log(`! ${n}`);
2470
2831
  if (results.some((r) => r.status === "failed")) process.exit(1);
2471
2832
  }
@@ -2501,6 +2862,157 @@ async function cmdSetupDomain(argv) {
2501
2862
  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.`);
2502
2863
  if (a.flag("check") && step.action !== "skip") process.exit(1);
2503
2864
  }
2865
+ /**
2866
+ * Create the Cloudflare Turnstile widget for the site and record its PUBLIC site key.
2867
+ *
2868
+ * The secret is returned by Cloudflare only at creation. It is never written to a config
2869
+ * file: it is piped to `wrangler secret put VITOPS_TURNSTILE_SECRET` on stdin (never argv,
2870
+ * never logged). If that fails, it is shown once on an interactive terminal and otherwise
2871
+ * not at all, with the rotate instruction. Never edits, rotates or deletes a widget.
2872
+ */
2873
+ async function cmdSetupTurnstile(argv) {
2874
+ const a = parse$1(argv, {
2875
+ ...SITE_OPTS,
2876
+ dry: {
2877
+ type: "boolean",
2878
+ default: false
2879
+ },
2880
+ check: {
2881
+ type: "boolean",
2882
+ default: false
2883
+ },
2884
+ "no-write": {
2885
+ type: "boolean",
2886
+ default: false
2887
+ },
2888
+ "no-secret": {
2889
+ type: "boolean",
2890
+ default: false
2891
+ }
2892
+ });
2893
+ const input = a.path("input");
2894
+ const cfg = await loadConfig(input, a.str("site-env"));
2895
+ const dry = a.flag("dry");
2896
+ const check = a.flag("check");
2897
+ const token = loadCloudflareToken();
2898
+ if (!token && !dry) fail(`${CLOUDFLARE_TOKEN_HINT}\n For this command the token needs "Account > Turnstile > Edit" (also set CLOUDFLARE_ACCOUNT_ID, or the token must be able to list accounts).`);
2899
+ const setup = {
2900
+ name: (() => {
2901
+ const n = cfg.organization?.name;
2902
+ return (typeof n === "string" ? n : n ? Object.values(n)[0] : void 0) ?? projectName();
2903
+ })(),
2904
+ domains: deriveDomains(cfg.site),
2905
+ siteKey: cfg.site.security?.turnstile?.siteKey
2906
+ };
2907
+ let accountId;
2908
+ let widgets = [];
2909
+ if (token) {
2910
+ const acct = await resolveAccountId(token, process.env.CLOUDFLARE_ACCOUNT_ID || void 0);
2911
+ if (!acct.ok) {
2912
+ if (!dry) fail(`Cloudflare account: ${acct.message}`);
2913
+ } else {
2914
+ accountId = acct.accountId;
2915
+ const listed = await listWidgets(token, accountId);
2916
+ if (!listed.ok) fail(`listing Turnstile widgets: ${listed.message}`);
2917
+ widgets = listed.widgets;
2918
+ }
2919
+ }
2920
+ const p = plan$5(setup, {
2921
+ accountId,
2922
+ widgets
2923
+ });
2924
+ if (dry || check) {
2925
+ console.log(formatPlan$7(p));
2926
+ if (check && hasDrift$3(p)) {
2927
+ console.error("\n✖ Turnstile is not set up");
2928
+ process.exit(1);
2929
+ }
2930
+ return;
2931
+ }
2932
+ const results = [];
2933
+ let siteKey = p.adopt?.sitekey;
2934
+ if (p.widget.action === "blocked") results.push({
2935
+ step: "widget",
2936
+ status: "blocked",
2937
+ detail: p.widget.detail
2938
+ });
2939
+ else if (p.widget.action === "create") {
2940
+ const r = await createWidget(token, accountId, {
2941
+ name: setup.name,
2942
+ domains: p.domains
2943
+ });
2944
+ if (!r.ok) results.push({
2945
+ step: "widget",
2946
+ status: "failed",
2947
+ detail: r.message ?? "failed"
2948
+ });
2949
+ else {
2950
+ siteKey = r.sitekey;
2951
+ results.push({
2952
+ step: "widget",
2953
+ status: "created",
2954
+ detail: r.sitekey
2955
+ });
2956
+ if (a.flag("no-secret")) console.error("! --no-secret: the secret was NOT saved and cannot be read back. Rotate it in the dashboard, then `wrangler secret put VITOPS_TURNSTILE_SECRET`.");
2957
+ else try {
2958
+ execFileSync("npx", [
2959
+ "wrangler",
2960
+ "secret",
2961
+ "put",
2962
+ "VITOPS_TURNSTILE_SECRET"
2963
+ ], {
2964
+ input: r.secret,
2965
+ stdio: [
2966
+ "pipe",
2967
+ "inherit",
2968
+ "inherit"
2969
+ ]
2970
+ });
2971
+ results.push({
2972
+ step: "secret",
2973
+ status: "created",
2974
+ detail: "VITOPS_TURNSTILE_SECRET (Worker secret)"
2975
+ });
2976
+ } catch {
2977
+ results.push({
2978
+ step: "secret",
2979
+ status: "failed",
2980
+ detail: "wrangler secret put failed"
2981
+ });
2982
+ if (process.stdout.isTTY) console.error(`! Save this now, it is shown once: VITOPS_TURNSTILE_SECRET=${r.secret}\n Then: npx wrangler secret put VITOPS_TURNSTILE_SECRET`);
2983
+ else console.error("! The secret was not saved and is not printed (non-interactive). Rotate it in the dashboard, then `wrangler secret put VITOPS_TURNSTILE_SECRET`.");
2984
+ }
2985
+ }
2986
+ } else results.push({
2987
+ step: "widget",
2988
+ status: "present",
2989
+ detail: p.widget.detail
2990
+ });
2991
+ if (siteKey && p.config.action === "create" && !a.flag("no-write")) {
2992
+ if (!input.endsWith(".json")) results.push({
2993
+ step: "site.json",
2994
+ status: "blocked",
2995
+ detail: `${input} is a module — add site.security.turnstile.siteKey: "${siteKey}" yourself`
2996
+ });
2997
+ else {
2998
+ const source = readFileSync(input, "utf8");
2999
+ const indent = /\n([ \t]+)"/.exec(source)?.[1];
3000
+ writeFileSync(input, JSON.stringify(patchSiteKey(JSON.parse(source), siteKey), null, indent?.includes(" ") ? " " : indent?.length ?? 2) + (source.endsWith("\n") ? "\n" : ""));
3001
+ results.push({
3002
+ step: "site.json",
3003
+ status: "created",
3004
+ detail: "site.security.turnstile.siteKey"
3005
+ });
3006
+ }
3007
+ } else if (p.config.action === "skip") results.push({
3008
+ step: "site.json",
3009
+ status: "present",
3010
+ detail: p.config.detail
3011
+ });
3012
+ console.log(`\n${formatSummary$5(results)}`);
3013
+ for (const r of p.reminders) console.log(` ${r}`);
3014
+ if (results.some((r) => r.status === "failed" || r.status === "blocked")) process.exit(1);
3015
+ }
2504
3016
  /** Forward to the CLI's own `search setup` / `ads setup` as a child process — see
2505
3017
  * this module's docblock for why (their behaviour must not be re-derived). */
2506
3018
  function forward(sub, argv) {
@@ -2523,6 +3035,7 @@ async function cmdSetup(argv) {
2523
3035
  case "repo": return cmdSetupRepo(rest);
2524
3036
  case "hosting": return cmdSetupHosting(rest);
2525
3037
  case "domain": return cmdSetupDomain(rest);
3038
+ case "turnstile": return cmdSetupTurnstile(rest);
2526
3039
  case "search":
2527
3040
  if (!forward("search", rest)) process.exit(1);
2528
3041
  return;
@@ -2575,7 +3088,7 @@ async function cmdSetup(argv) {
2575
3088
  case "--help":
2576
3089
  console.log(SETUP_HELP);
2577
3090
  return;
2578
- default: fail(`unknown "setup" subcommand "${sub}" (expected: repo | hosting | domain | search | ads | all). Try: vitops --help`);
3091
+ default: fail(`unknown "setup" subcommand "${sub}" (expected: repo | hosting | domain | turnstile | search | ads | all). Try: vitops --help`);
2579
3092
  }
2580
3093
  }
2581
3094
 
@@ -3329,6 +3842,18 @@ const UTIL_FAMILIES = [
3329
3842
  "fill",
3330
3843
  "stroke"
3331
3844
  ];
3845
+ /** Class families the framework owns in every format, for variant-spelling checks. */
3846
+ const FRAMEWORK_FAMILY = new RegExp(`^(${[
3847
+ ...UTIL_FAMILIES,
3848
+ "font",
3849
+ "drop-shadow",
3850
+ "split",
3851
+ "flex",
3852
+ "items",
3853
+ "justify",
3854
+ "gap",
3855
+ "space"
3856
+ ].join("|")})(-|$)`);
3332
3857
  /**
3333
3858
  * The modifiers actually emitted for one family + role, read back out of the
3334
3859
  * generator's own class list.
@@ -3384,6 +3909,13 @@ function judge(raw, v, format) {
3384
3909
  reason: `\`@${cq[1]}:\` container-query variants are a tailwind-format spelling; the ${format} format emits pre-expanded classes`,
3385
3910
  suggestion: `${cq[1]}-${cq[2]}`
3386
3911
  };
3912
+ const sv = /^(hover|focus|focus-visible|focus-within|active|disabled|dark|group-hover):(.+)$/.exec(raw);
3913
+ if (sv && format !== "tailwind" && FRAMEWORK_FAMILY.test(sv[2])) return {
3914
+ cls: raw,
3915
+ severity: "error",
3916
+ reason: `\`${sv[1]}:\` state variants are a tailwind-format spelling; the ${format} format emits no such class`,
3917
+ suggestion: `${sv[1]}-${sv[2]} where an effect class exists, otherwise a pattern state`
3918
+ };
3387
3919
  const bp = /^(sm|md|lg|xl)-(.+)$/.exec(cls);
3388
3920
  if (bp && format === "tailwind") return {
3389
3921
  cls: raw,
@@ -3496,7 +4028,9 @@ function lintSource(files, v, format) {
3496
4028
  * vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
3497
4029
  * vitops setup repo | hosting | domain | search | ads | all [--dry] [--check]
3498
4030
  * vitops email setup [domain] [--dry] [--check] | check [--strict] [--skip]
4031
+ * vitops emdash prepare-db | check-prerender [dir]
3499
4032
  * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
4033
+ * vitops audit [--url <base>] [--build] [--pages <list>] [--warn] [--out <dir>]
3500
4034
  *
3501
4035
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
3502
4036
  * Every client brings their own consumer-editable design-system.json.
@@ -4145,6 +4679,20 @@ function generatorAssetsDir() {
4145
4679
  }
4146
4680
  const AGENTS_START = "<!-- vitops:start -->";
4147
4681
  const AGENTS_END = "<!-- vitops:end -->";
4682
+ /**
4683
+ * Input path for the read-only commands (`docs`, `lint`) when `-i` is absent.
4684
+ *
4685
+ * `site.json` is the file `vitops init --site` and every scaffold write, so it
4686
+ * is the default; a bare `design-system.json` is the fallback for projects that
4687
+ * keep tokens on their own. An explicit `-i` is never second-guessed. If
4688
+ * neither exists the legacy name is returned so the "config not found" message
4689
+ * names a path a reader recognises.
4690
+ */
4691
+ function defaultDesignInput(argv, value) {
4692
+ if (argv.some((a) => a === "-i" || a === "--input" || a.startsWith("--input="))) return value;
4693
+ for (const name of ["site.json", "design-system.json"]) if (existsSync(resolve(name))) return name;
4694
+ return value;
4695
+ }
4148
4696
  /** Print a live reference doc (rendered from the project's config) to stdout. */
4149
4697
  async function cmdDocs(argv) {
4150
4698
  const { values, positionals } = parseArgs({
@@ -4171,7 +4719,7 @@ async function cmdDocs(argv) {
4171
4719
  }
4172
4720
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
4173
4721
  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());
4722
+ const docs = names.every((n) => TOPICS[n].external) ? {} : generateDocs(loadDesignSystem(resolve(defaultDesignInput(argv, values.input)), values.theme), generatorAssetsDir());
4175
4723
  const parts = [];
4176
4724
  for (const name of names) {
4177
4725
  const t = TOPICS[name];
@@ -4229,7 +4777,7 @@ function cmdLint(argv) {
4229
4777
  });
4230
4778
  const format = values.format;
4231
4779
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
4232
- const ds = loadDesignSystem(resolve(values.input), values.theme);
4780
+ const ds = loadDesignSystem(resolve(defaultDesignInput(argv, values.input)), values.theme);
4233
4781
  const srcDir = resolve(values.src);
4234
4782
  if (!positionals.length && !existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
4235
4783
  const palette = expandPalette(ds.colors.palette);
@@ -4474,8 +5022,10 @@ async function main() {
4474
5022
  case "ads": return cmdAds(rest);
4475
5023
  case "crm": return cmdCrm(rest);
4476
5024
  case "email": return cmdEmail(rest);
5025
+ case "emdash": return cmdEmdash(rest);
4477
5026
  case "setup": return cmdSetup(rest);
4478
5027
  case "media": return cmdMedia(rest);
5028
+ case "audit": return cmdAudit(rest);
4479
5029
  default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
4480
5030
  }
4481
5031
  }