@getvitops/cli 8.3.0-alpha.3 → 8.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/cli.mjs +218 -29
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +5 -54
package/dist/cli.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
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, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, extname, join, parse, relative, resolve } from "node:path";
|
|
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";
|
|
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, validateConfig } from "@getvitops/generator";
|
|
8
8
|
import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
|
|
9
9
|
import { SCOPES, SNAPSHOT_PATH, adcCredentialsPath, adcQuotaProject, collectEntries, formatPlan, googleAccessToken, inspectUrl, keyFileContents, newKey, parseAdcUser, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
|
|
10
10
|
import { REVIEWS_CACHE_PATH, fetchReviews, formatPlan as formatPlan$1, mergeCache, plan as plan$1, readCache, selectReviews, toCache, writeCache } from "@getvitops/utils/reviews";
|
|
@@ -868,6 +868,9 @@ const EMDASH_HELP = `vitops emdash — build-time steps for an EmDash site
|
|
|
868
868
|
is not a real page
|
|
869
869
|
vitops emdash build [d] astro build, then check-prerender; also fails when the build
|
|
870
870
|
reports a block type with no component (content dropped)
|
|
871
|
+
vitops emdash form-template --from <seed.json> [--name contact] [--site site.json] [--dry]
|
|
872
|
+
Copy a vitops_form block into site.templates (the privacy
|
|
873
|
+
policy's "what we collect" and a check-prerender input)
|
|
871
874
|
|
|
872
875
|
Run \`vitops --help\` for the full option list for each subcommand.
|
|
873
876
|
`;
|
|
@@ -936,7 +939,7 @@ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
|
|
|
936
939
|
/** The help text to print for `vitops <command> [sub] --help`. */
|
|
937
940
|
function helpFor(command, argv) {
|
|
938
941
|
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
|
|
939
|
-
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 vitops emdash build [d] astro build, then check-prerender; also fails when the build\n reports a block type with no component (content dropped)\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 [sync] [options] Link the agent skills + keep a short managed block at the top of AGENTS.md\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). Only the fenced\n `<!-- vitops:start -->` block is written: added at the top when absent,\n replaced in place when present, everything else left alone. It names the\n config and links the vitops skills linked under .agents/skills (the CLI's own\n and those of an installed @getvitops/skills, which this command also links).\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 vitops emdash build [d] astro build, then check-prerender; fails on blocks with no renderer\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 --strict Also fail (not just warn) when a page uses a type-role class (font-display, …)\n the built CSS has no rule for, links to pages the build did not produce, a page without\n exactly one <h1>, or a form with nowhere to send it (reads ./site.json, or --site=<path>).\n\nEmdash build options:\n [dir] Runs `astro build`, then check-prerender on [dir] (default: dist/client). Also fails\n when the build reports a block type with no component (\"PortableText\n [components.type] is missing …\"): the page was published without that content.\n --strict Passed through to check-prerender.\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. The page list is the built sitemap\n plus links in seed/seed.json's primary* and footer* menus; only\n when neither exists are / /privacy /terms /cookies /contact\n guessed. A 404 skips a page (with its reason in the report); a\n 404 on / fails the run.\n --no-defaults Never guess the default 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: vp add -D playwright @axe-core/playwright lighthouse\n && vp exec playwright install chromium (checked before any server starts). 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";
|
|
942
|
+
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 vitops emdash build [d] astro build, then check-prerender; also fails when the build\n reports a block type with no component (content dropped)\n vitops emdash form-template --from <seed.json> [--name contact] [--site site.json] [--dry]\n Copy a vitops_form block into site.templates (the privacy\n policy's \"what we collect\" and a check-prerender input)\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 [sync] [options] Link the agent skills + keep a short managed block at the top of AGENTS.md\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). Only the fenced\n `<!-- vitops:start -->` block is written: added at the top when absent,\n replaced in place when present, everything else left alone. It names the\n config and links the vitops skills linked under .agents/skills (the CLI's own\n and those of an installed @getvitops/skills, which this command also links).\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 vitops emdash build [d] astro build, then check-prerender; fails on blocks with no renderer\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 --strict Also fail (not just warn) when a page uses a type-role class (font-display, …)\n the built CSS has no rule for, links to pages the build did not produce, a page without\n exactly one <h1>, or a form with nowhere to send it (reads ./site.json, or --site=<path>).\n\nEmdash build options:\n [dir] Runs `astro build`, then check-prerender on [dir] (default: dist/client). Also fails\n when the build reports a block type with no component (\"PortableText\n [components.type] is missing …\"): the page was published without that content.\n --strict Passed through to check-prerender.\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. The page list is the built sitemap\n plus links in seed/seed.json's primary* and footer* menus; only\n when neither exists are / /privacy /terms /cookies /contact\n guessed. A 404 skips a page (with its reason in the report); a\n 404 on / fails the run.\n --no-defaults Never guess the default 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: vp add -D playwright @axe-core/playwright lighthouse\n && vp exec playwright install chromium (checked before any server starts). 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";
|
|
940
943
|
}
|
|
941
944
|
|
|
942
945
|
// src/commands/search.ts
|
|
@@ -1711,11 +1714,13 @@ async function cmdAdsLint(argv) {
|
|
|
1711
1714
|
const a = parse$1(argv, SITE_OPTS);
|
|
1712
1715
|
const cfg = await siteConfig(a);
|
|
1713
1716
|
const properties = toAdProperties(cfg, void 0);
|
|
1714
|
-
|
|
1717
|
+
const phones = cfg.site.tracking?.phoneNumbers;
|
|
1718
|
+
if (properties.length === 0 && !phones) {
|
|
1715
1719
|
console.log("· no site.ads properties — nothing to check");
|
|
1716
1720
|
return;
|
|
1717
1721
|
}
|
|
1718
1722
|
const findings = [];
|
|
1723
|
+
const notes = [];
|
|
1719
1724
|
const trackingOn = cfg.site.tracking?.enabled === true;
|
|
1720
1725
|
for (const p of properties) {
|
|
1721
1726
|
const platform = AD_PLATFORMS[p.provider];
|
|
@@ -1731,6 +1736,14 @@ async function cmdAdsLint(argv) {
|
|
|
1731
1736
|
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`);
|
|
1732
1737
|
}
|
|
1733
1738
|
}
|
|
1739
|
+
if (phones) {
|
|
1740
|
+
const declared = (cfg.site.legal?.privacyPolicy?.processors ?? []).map((p) => p.name.toLowerCase());
|
|
1741
|
+
if (!phones.provider) findings.push("site.tracking.phoneNumbers has no provider — state which call-tracking service holds these numbers (twilio, callrail, …) so the privacy policy can name it");
|
|
1742
|
+
else if (!declared.some((n) => n.includes(phones.provider.toLowerCase()))) findings.push(`site.tracking.phoneNumbers.provider "${phones.provider}" records callers' numbers but is not in site.legal.privacyPolicy.processors — the generated privacy policy will not name it`);
|
|
1743
|
+
}
|
|
1744
|
+
const google = properties.find((p) => p.provider === "google" && p.enabled !== false);
|
|
1745
|
+
if (google?.callTracking && normalizeConversionLabels(google.conversionLabel).call) notes.push("google: callTracking and conversionLabel.call are both set — one call can count twice. In Google Ads, make one of the two conversion actions secondary.");
|
|
1746
|
+
for (const n of notes) console.warn(`! ${n}`);
|
|
1734
1747
|
if (findings.length === 0) {
|
|
1735
1748
|
console.log(`✓ ${properties.length} ad propert${properties.length === 1 ? "y" : "ies"} check out`);
|
|
1736
1749
|
return;
|
|
@@ -2514,6 +2527,26 @@ function brokenInternalLinks(html, routes) {
|
|
|
2514
2527
|
}
|
|
2515
2528
|
return [...broken].sort();
|
|
2516
2529
|
}
|
|
2530
|
+
/**
|
|
2531
|
+
* URLs a sitemap lists that the build did not produce a page for. A collection with no
|
|
2532
|
+
* `urlPattern` is listed as `/<collection>/<slug>` while the site serves `/<slug>`, so the
|
|
2533
|
+
* sitemap submitted to a search engine would be all dead links. Only same-path `<loc>`s are judged.
|
|
2534
|
+
*/
|
|
2535
|
+
function sitemapOrphans(xml, routes) {
|
|
2536
|
+
const orphans = /* @__PURE__ */ new Set();
|
|
2537
|
+
for (const m of xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) {
|
|
2538
|
+
let path;
|
|
2539
|
+
try {
|
|
2540
|
+
path = new URL(m[1]).pathname;
|
|
2541
|
+
} catch {
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
if (/\.[a-z0-9]{1,8}$/i.test(path)) continue;
|
|
2545
|
+
const norm = path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
2546
|
+
if (!routes.has(norm)) orphans.add(norm);
|
|
2547
|
+
}
|
|
2548
|
+
return [...orphans].sort();
|
|
2549
|
+
}
|
|
2517
2550
|
/** Whether a built page carries the `vitops_form` block's post (to the conversion route). */
|
|
2518
2551
|
function hasConversionForm(html) {
|
|
2519
2552
|
return /<form[^>]*\saction\s*=\s*["']\/api\/track["']/i.test(html);
|
|
@@ -2569,6 +2602,52 @@ function uninventoriedFields(config, built) {
|
|
|
2569
2602
|
return built.filter((name) => !known.has(name));
|
|
2570
2603
|
}
|
|
2571
2604
|
|
|
2605
|
+
// src/form-template.ts
|
|
2606
|
+
/**
|
|
2607
|
+
* `vitops emdash form-template`: copy a saved `vitops_form` block into `site.templates` so the
|
|
2608
|
+
* generated privacy policy and `check-prerender` know what the form collects. The block → entry
|
|
2609
|
+
* conversion is `@getvitops/emdash`'s `formTemplateFromBlock` (the same resolver the renderer
|
|
2610
|
+
* uses, so names match what is submitted); this module is the part that finds blocks and edits
|
|
2611
|
+
* the config, kept pure so it is testable without a project.
|
|
2612
|
+
*/
|
|
2613
|
+
/** Every `vitops_form` block anywhere in a JSON document (a seed, a content export). */
|
|
2614
|
+
function findFormBlocks(doc) {
|
|
2615
|
+
const found = [];
|
|
2616
|
+
const walk = (node) => {
|
|
2617
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
2618
|
+
if (!node || typeof node !== "object") return;
|
|
2619
|
+
const obj = node;
|
|
2620
|
+
if (obj["_type"] === "vitops_form") found.push(obj);
|
|
2621
|
+
Object.values(obj).forEach(walk);
|
|
2622
|
+
};
|
|
2623
|
+
walk(doc);
|
|
2624
|
+
return found;
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* Set `site.templates[name]` on the RAW on-disk config, leaving everything else as it was (the
|
|
2628
|
+
* rule `designSystemPath()` follows: never normalise the file into a copy nothing builds from).
|
|
2629
|
+
* Returns the updated object and whether it changed, so a re-run is a no-op.
|
|
2630
|
+
*/
|
|
2631
|
+
function withTemplate(config, name, template) {
|
|
2632
|
+
const root = config && typeof config === "object" ? config : {};
|
|
2633
|
+
const site = root["site"] && typeof root["site"] === "object" ? root["site"] : {};
|
|
2634
|
+
const templates = site["templates"] && typeof site["templates"] === "object" ? site["templates"] : {};
|
|
2635
|
+
const changed = JSON.stringify(templates[name]) !== JSON.stringify(template);
|
|
2636
|
+
return {
|
|
2637
|
+
config: {
|
|
2638
|
+
...root,
|
|
2639
|
+
site: {
|
|
2640
|
+
...site,
|
|
2641
|
+
templates: {
|
|
2642
|
+
...templates,
|
|
2643
|
+
[name]: template
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
},
|
|
2647
|
+
changed
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2572
2651
|
// src/commands/emdash.ts
|
|
2573
2652
|
/**
|
|
2574
2653
|
* `vitops emdash` — build-time steps for an EmDash site on the Cloudflare adapter.
|
|
@@ -2580,6 +2659,7 @@ function uninventoriedFields(config, built) {
|
|
|
2580
2659
|
*
|
|
2581
2660
|
* prepare-db migrate and seed the LOCAL D1 the build prerenders from
|
|
2582
2661
|
* check-prerender fail if a prerendered page is not a real page
|
|
2662
|
+
* form-template copy a vitops_form block into site.templates
|
|
2583
2663
|
* build `astro build`, then the same checks, plus a failure for any block with no renderer
|
|
2584
2664
|
*
|
|
2585
2665
|
* Why both exist: `astro build` prerenders every page inside a local workerd against a local D1
|
|
@@ -2599,12 +2679,19 @@ function freePort() {
|
|
|
2599
2679
|
});
|
|
2600
2680
|
});
|
|
2601
2681
|
}
|
|
2602
|
-
|
|
2682
|
+
/**
|
|
2683
|
+
* Poll until `test` passes. `died` is asked after each miss and may return why waiting is pointless
|
|
2684
|
+
* (the dev server printed an error and is gone), so a broken config fails in seconds, not after the
|
|
2685
|
+
* full timeout.
|
|
2686
|
+
*/
|
|
2687
|
+
async function until(test, what, ms = 12e4, died) {
|
|
2603
2688
|
const deadline = Date.now() + ms;
|
|
2604
2689
|
while (Date.now() < deadline) {
|
|
2605
2690
|
try {
|
|
2606
2691
|
if (await test()) return;
|
|
2607
2692
|
} catch {}
|
|
2693
|
+
const why = died?.();
|
|
2694
|
+
if (why) throw new Error(`${what} did not start: ${why}`);
|
|
2608
2695
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
2609
2696
|
}
|
|
2610
2697
|
throw new Error(`timed out waiting for ${what}`);
|
|
@@ -2615,14 +2702,49 @@ const log = (msg) => console.log(`prepare-db: ${msg}`);
|
|
|
2615
2702
|
* output through pipes would leave this process waiting on the daemon's inherited handles,
|
|
2616
2703
|
* which looks exactly like a hang. Output is ignored and every call has a timeout.
|
|
2617
2704
|
*/
|
|
2618
|
-
const
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2705
|
+
const DEV_LOG = ".vitops/prepare-db.log";
|
|
2706
|
+
const astro = (args) => {
|
|
2707
|
+
mkdirSync(".vitops", { recursive: true });
|
|
2708
|
+
const fd = openSync(DEV_LOG, "a");
|
|
2709
|
+
try {
|
|
2710
|
+
return spawnSync(existsSync("node_modules/.bin/astro") ? "node_modules/.bin/astro" : "astro", args, {
|
|
2711
|
+
stdio: [
|
|
2712
|
+
"ignore",
|
|
2713
|
+
fd,
|
|
2714
|
+
fd
|
|
2715
|
+
],
|
|
2716
|
+
timeout: 3e5,
|
|
2717
|
+
env: {
|
|
2718
|
+
...process.env,
|
|
2719
|
+
CI: "1"
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
} finally {
|
|
2723
|
+
closeSync(fd);
|
|
2724
|
+
}
|
|
2725
|
+
};
|
|
2726
|
+
const logTail = (lines = 30) => {
|
|
2727
|
+
try {
|
|
2728
|
+
return readFileSync(DEV_LOG, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
|
|
2729
|
+
} catch {
|
|
2730
|
+
return "";
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
const devError = () => {
|
|
2734
|
+
return logTail(60).split("\n").find((l) => /\[(?:ERROR|error)\]|^\s*(?:\w+)?Error:|ERR_/.test(l))?.trim();
|
|
2735
|
+
};
|
|
2736
|
+
/** `site.json` must parse and validate before a dev server is asked to read it. */
|
|
2737
|
+
function checkSiteConfig(siteFile = "site.json") {
|
|
2738
|
+
if (!existsSync(siteFile)) return;
|
|
2739
|
+
let parsed;
|
|
2740
|
+
try {
|
|
2741
|
+
parsed = JSON.parse(readFileSync(siteFile, "utf8"));
|
|
2742
|
+
} catch (e) {
|
|
2743
|
+
fail(`${siteFile} is not valid JSON: ${e.message}`);
|
|
2744
|
+
}
|
|
2745
|
+
const result = validateConfig(parsed);
|
|
2746
|
+
if (!result.ok) fail(`${siteFile} is not a valid site config:\n` + result.errors.map((i) => ` ${(i.path ?? []).join(".") || "(root)"}: ${i.message}`).join("\n") + "\n\nThe dev server would exit on this without saying why. Fix it, or run `vitops validate`.");
|
|
2747
|
+
}
|
|
2626
2748
|
/**
|
|
2627
2749
|
* Starts `astro dev` once, calls EmDash's `dev-bypass` setup endpoint (the non-interactive
|
|
2628
2750
|
* setup wizard: it runs migrations, `dev: 'auto'`, and applies the seed), checks the home page
|
|
@@ -2635,6 +2757,8 @@ async function cmdPrepareDb() {
|
|
|
2635
2757
|
log("SKIP_PREPARE_LOCAL_DB set — leaving the local database as it is.");
|
|
2636
2758
|
return;
|
|
2637
2759
|
}
|
|
2760
|
+
checkSiteConfig();
|
|
2761
|
+
writeFileSync(DEV_LOG, "");
|
|
2638
2762
|
const port = await freePort();
|
|
2639
2763
|
const base = `http://127.0.0.1:${port}`;
|
|
2640
2764
|
log(`starting astro dev on ${base} ...`);
|
|
@@ -2649,7 +2773,7 @@ async function cmdPrepareDb() {
|
|
|
2649
2773
|
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.`);
|
|
2650
2774
|
let failure = null;
|
|
2651
2775
|
try {
|
|
2652
|
-
await until(async () => (await fetch(`${base}/_emdash/api/setup/status`)).ok, "astro dev");
|
|
2776
|
+
await until(async () => (await fetch(`${base}/_emdash/api/setup/status`)).ok, "astro dev", 12e4, devError);
|
|
2653
2777
|
log("dev server is up; migrating and seeding (first run can take a minute) ...");
|
|
2654
2778
|
const setup = await fetch(`${base}/_emdash/api/setup/dev-bypass?redirect=/`, {
|
|
2655
2779
|
redirect: "manual",
|
|
@@ -2666,6 +2790,10 @@ async function cmdPrepareDb() {
|
|
|
2666
2790
|
} finally {
|
|
2667
2791
|
astro(["dev", "stop"]);
|
|
2668
2792
|
}
|
|
2793
|
+
if (failure) {
|
|
2794
|
+
const tail = logTail();
|
|
2795
|
+
if (tail) console.error(`prepare-db: last of ${DEV_LOG}:\n${tail}\n`);
|
|
2796
|
+
}
|
|
2669
2797
|
if (failure) fail(`${failure.message}\nRefusing to continue: prerendering now would publish error pages as the site.`);
|
|
2670
2798
|
}
|
|
2671
2799
|
function* filesWith(dir, ext) {
|
|
@@ -2691,6 +2819,7 @@ function cmdCheckPrerender(argv) {
|
|
|
2691
2819
|
const css = cssFiles(root).map((f) => readFileSync(f, "utf8")).join("\n");
|
|
2692
2820
|
let count = 0;
|
|
2693
2821
|
for (const file of htmlFiles(root)) {
|
|
2822
|
+
if (relative(root, file).startsWith("vitops/")) continue;
|
|
2694
2823
|
count += 1;
|
|
2695
2824
|
const html = readFileSync(file, "utf8");
|
|
2696
2825
|
const why = prerenderProblem(html);
|
|
@@ -2742,6 +2871,12 @@ function cmdCheckPrerender(argv) {
|
|
|
2742
2871
|
}
|
|
2743
2872
|
}
|
|
2744
2873
|
}
|
|
2874
|
+
const orphaned = [...filesWith(root, ".xml")].filter((f) => /sitemap[^/\\]*\.xml$/.test(f)).flatMap((f) => sitemapOrphans(readFileSync(f, "utf8"), routes));
|
|
2875
|
+
if (orphaned.length) {
|
|
2876
|
+
const note = `the sitemap lists URLs the build did not produce (a search engine would be sent to 404s):\n${orphaned.slice(0, 10).map((u) => ` ${u}`).join("\n")}${orphaned.length > 10 ? `\n … and ${orphaned.length - 10} more` : ""}\n\nA collection with no \`urlPattern\` is listed as /<collection>/<slug>. Set the pattern on the collection (the seed's \`urlPattern\`, or Content types in the admin) to the path the site serves.`;
|
|
2877
|
+
if (strict) fail(note);
|
|
2878
|
+
console.warn(`check-prerender: warning: ${note}`);
|
|
2879
|
+
}
|
|
2745
2880
|
if (dead.length) {
|
|
2746
2881
|
const note = `links to pages the build did not produce (they would 404):\n${dead.join("\n")}\n\nPublish the page, or mark the menu item as a planned link by adding \`draft\` to its CSS classes so it is not rendered until the page exists.`;
|
|
2747
2882
|
if (strict) fail(note);
|
|
@@ -2755,6 +2890,46 @@ function cmdCheckPrerender(argv) {
|
|
|
2755
2890
|
console.log(`check-prerender: ${count} prerendered page(s) look like real pages.`);
|
|
2756
2891
|
}
|
|
2757
2892
|
/**
|
|
2893
|
+
* `vitops emdash form-template --from <seed.json|export.json> [--name <key>] [--site <site.json>]`:
|
|
2894
|
+
* copy a `vitops_form` block's fields into `site.templates`, so the generated privacy policy and
|
|
2895
|
+
* `check-prerender` know what the form collects. Hand-written entries are easy to get wrong (a
|
|
2896
|
+
* field type the policy's schema does not have fails with only a path); this uses the renderer's
|
|
2897
|
+
* own resolver, so the names are the ones the form submits.
|
|
2898
|
+
*/
|
|
2899
|
+
async function cmdFormTemplate(argv) {
|
|
2900
|
+
const flag = (name) => {
|
|
2901
|
+
const i = argv.indexOf(`--${name}`);
|
|
2902
|
+
if (i >= 0) return argv[i + 1];
|
|
2903
|
+
return argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
2904
|
+
};
|
|
2905
|
+
const from = flag("from") ?? (existsSync("seed/seed.json") ? "seed/seed.json" : void 0);
|
|
2906
|
+
const siteFile = flag("site") ?? "site.json";
|
|
2907
|
+
const dry = argv.includes("--dry");
|
|
2908
|
+
if (!from) fail("give --from <seed.json or content export containing a vitops_form block>.");
|
|
2909
|
+
if (!existsSync(from)) fail(`${from} does not exist.`);
|
|
2910
|
+
if (!existsSync(siteFile)) fail(`${siteFile} does not exist (use --site <path>).`);
|
|
2911
|
+
const blocks = findFormBlocks(JSON.parse(readFileSync(from, "utf8")));
|
|
2912
|
+
if (blocks.length === 0) fail(`no vitops_form block found in ${from}.`);
|
|
2913
|
+
const requested = flag("name");
|
|
2914
|
+
if (blocks.length > 1 && !requested) fail(`${blocks.length} vitops_form blocks in ${from}; this writes one entry. Name it with --name <key> and narrow --from to the one you mean (a content export of a single entry).`);
|
|
2915
|
+
const name = requested ?? "contact";
|
|
2916
|
+
let emdash;
|
|
2917
|
+
try {
|
|
2918
|
+
const resolved = createRequire(join(process.cwd(), "noop.js")).resolve("@getvitops/emdash");
|
|
2919
|
+
emdash = await import(pathToFileURL(resolved).href);
|
|
2920
|
+
} catch {
|
|
2921
|
+
fail("@getvitops/emdash is not installed in this project (run from the site root).");
|
|
2922
|
+
}
|
|
2923
|
+
const template = emdash.formTemplateFromBlock(blocks[0]);
|
|
2924
|
+
const { config, changed } = withTemplate(JSON.parse(readFileSync(siteFile, "utf8")), name, template);
|
|
2925
|
+
const check = validateConfig(config);
|
|
2926
|
+
if (!check.ok) fail(`the template would make ${siteFile} invalid:\n` + check.errors.map((i) => ` ${(i.path ?? []).join(".")}: ${i.message}`).join("\n"));
|
|
2927
|
+
if (!changed) return console.log(`form-template: site.templates.${name} is already up to date.`);
|
|
2928
|
+
if (dry) return console.log(JSON.stringify({ [name]: template }, null, 2));
|
|
2929
|
+
writeFileSync(siteFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
2930
|
+
console.log(`form-template: wrote site.templates.${name} to ${siteFile}. Run your formatter on it.`);
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2758
2933
|
* `astro build` and then `check-prerender`, failing on any block type EmDash reported as having
|
|
2759
2934
|
* no component. That report is a build-log warning, not an exit code, so the build's output is
|
|
2760
2935
|
* streamed through unchanged and scanned afterwards.
|
|
@@ -2790,12 +2965,13 @@ async function cmdEmdash(argv) {
|
|
|
2790
2965
|
case "prepare-db": return cmdPrepareDb();
|
|
2791
2966
|
case "check-prerender": return cmdCheckPrerender(rest);
|
|
2792
2967
|
case "build": return cmdBuild(rest);
|
|
2968
|
+
case "form-template": return cmdFormTemplate(rest);
|
|
2793
2969
|
case void 0:
|
|
2794
2970
|
case "-h":
|
|
2795
2971
|
case "--help":
|
|
2796
2972
|
console.log(EMDASH_HELP);
|
|
2797
2973
|
return;
|
|
2798
|
-
default: fail(`unknown "emdash" subcommand "${sub}" (expected: prepare-db | check-prerender | build). Try: vitops --help`);
|
|
2974
|
+
default: fail(`unknown "emdash" subcommand "${sub}" (expected: prepare-db | check-prerender | build | form-template). Try: vitops --help`);
|
|
2799
2975
|
}
|
|
2800
2976
|
}
|
|
2801
2977
|
|
|
@@ -3489,6 +3665,20 @@ function linkSkill(linkPath, targetDir) {
|
|
|
3489
3665
|
}
|
|
3490
3666
|
const AGENTS_START = "<!-- vitops:start -->";
|
|
3491
3667
|
const AGENTS_END = "<!-- vitops:end -->";
|
|
3668
|
+
/** Whether the project depends on EmDash: the `emdash-*` skills only make sense there. */
|
|
3669
|
+
function isEmdashSite(cwd) {
|
|
3670
|
+
try {
|
|
3671
|
+
const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8"));
|
|
3672
|
+
return Boolean({
|
|
3673
|
+
...pkg.dependencies,
|
|
3674
|
+
...pkg.devDependencies
|
|
3675
|
+
}["emdash"]);
|
|
3676
|
+
} catch {
|
|
3677
|
+
return false;
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
/** Skill names carry their scope: `emdash-*` needs an EmDash site, `vitops-*` and the rest apply anywhere. */
|
|
3681
|
+
const appliesTo = (skill, emdash) => emdash || !skill.startsWith("emdash-");
|
|
3492
3682
|
/** A frontmatter `description`, whether a plain line or a folded (`>-`) scalar. */
|
|
3493
3683
|
function descriptionOf(skillMd) {
|
|
3494
3684
|
const fm = /^---\n([\s\S]*?)\n---/.exec(skillMd)?.[1] ?? "";
|
|
@@ -3501,10 +3691,15 @@ function descriptionOf(skillMd) {
|
|
|
3501
3691
|
* One short line saying when to load a skill: its "Use when/for/before …" sentence if it has one
|
|
3502
3692
|
* (that is the part that answers "is this the one?"), else its first sentence.
|
|
3503
3693
|
*/
|
|
3504
|
-
function skillSummary(skillMd, max =
|
|
3694
|
+
function skillSummary(skillMd, max = 120) {
|
|
3505
3695
|
const description = descriptionOf(skillMd);
|
|
3506
3696
|
const line = /\bUse (?:when|for|before|ONLY)\b[^.]*/.exec(description)?.[0] ?? description.split(/(?<=[.!?])\s/)[0] ?? description;
|
|
3507
|
-
|
|
3697
|
+
if (line.length <= max) return line;
|
|
3698
|
+
const head = line.slice(0, max);
|
|
3699
|
+
const cut = Math.max(head.lastIndexOf(", "), head.lastIndexOf(" or "), head.lastIndexOf(" — "));
|
|
3700
|
+
let out = cut > max / 2 ? head.slice(0, cut) : head.slice(0, head.lastIndexOf(" "));
|
|
3701
|
+
if ((out.match(/\(/g) ?? []).length > (out.match(/\)/g) ?? []).length) out = out.slice(0, out.lastIndexOf("("));
|
|
3702
|
+
return out.replace(/[\s,;:—-]+$/, "");
|
|
3508
3703
|
}
|
|
3509
3704
|
/**
|
|
3510
3705
|
* The skills that belong in the block: those under `.agents/skills` that are links into an installed
|
|
@@ -3514,8 +3709,10 @@ function skillSummary(skillMd, max = 90) {
|
|
|
3514
3709
|
function discoverBlockSkills(cwd) {
|
|
3515
3710
|
const dir = resolve(cwd, ".agents", "skills");
|
|
3516
3711
|
if (!existsSync(dir)) return [];
|
|
3712
|
+
const emdash = isEmdashSite(cwd);
|
|
3517
3713
|
const out = [];
|
|
3518
3714
|
for (const name of readdirSync(dir).sort()) {
|
|
3715
|
+
if (!appliesTo(name, emdash)) continue;
|
|
3519
3716
|
const link = join(dir, name);
|
|
3520
3717
|
const skillFile = join(link, "SKILL.md");
|
|
3521
3718
|
if (!lstatSync(link).isSymbolicLink() || !existsSync(skillFile)) continue;
|
|
@@ -3535,6 +3732,7 @@ function discoverBlockSkills(cwd) {
|
|
|
3535
3732
|
*/
|
|
3536
3733
|
function linkPackagedSkills(cwd) {
|
|
3537
3734
|
const warnings = [];
|
|
3735
|
+
const emdash = isEmdashSite(cwd);
|
|
3538
3736
|
let dir = resolve(cwd);
|
|
3539
3737
|
const { root } = parse(dir);
|
|
3540
3738
|
for (;;) {
|
|
@@ -3542,7 +3740,7 @@ function linkPackagedSkills(cwd) {
|
|
|
3542
3740
|
if (existsSync(skills)) {
|
|
3543
3741
|
for (const name of readdirSync(skills)) {
|
|
3544
3742
|
const target = join(skills, name);
|
|
3545
|
-
if (!existsSync(join(target, "SKILL.md"))) continue;
|
|
3743
|
+
if (!existsSync(join(target, "SKILL.md")) || !appliesTo(name, emdash)) continue;
|
|
3546
3744
|
for (const base of [".agents", ".claude"]) {
|
|
3547
3745
|
const warn = linkSkill(resolve(cwd, base, "skills", name), target);
|
|
3548
3746
|
if (warn) warnings.push(warn);
|
|
@@ -3567,8 +3765,7 @@ function renderAgentsBlock(opts) {
|
|
|
3567
3765
|
"",
|
|
3568
3766
|
...opts.skills.map((s) => `- [${s.name}](${s.path})${s.summary ? ` — ${s.summary}` : ""}`),
|
|
3569
3767
|
...opts.skills.length ? [""] : [],
|
|
3570
|
-
"Reference, rendered from this project: `vitops docs
|
|
3571
|
-
`<!-- managed by \`vitops agents sync\` · @getvitops/cli@${opts.version} — edit outside this block -->`,
|
|
3768
|
+
"Reference, rendered from this project: `vp exec vitops docs [topic]` (no topic lists them).",
|
|
3572
3769
|
AGENTS_END
|
|
3573
3770
|
].join("\n");
|
|
3574
3771
|
}
|
|
@@ -5046,13 +5243,6 @@ async function cmdReviews(argv) {
|
|
|
5046
5243
|
}
|
|
5047
5244
|
if (failed) process.exit(1);
|
|
5048
5245
|
}
|
|
5049
|
-
function cliVersion() {
|
|
5050
|
-
try {
|
|
5051
|
-
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
5052
|
-
} catch {
|
|
5053
|
-
return "0.0.0";
|
|
5054
|
-
}
|
|
5055
|
-
}
|
|
5056
5246
|
function generatorAssetsDir() {
|
|
5057
5247
|
const pkg = createRequire(import.meta.url).resolve("@getvitops/generator/package.json");
|
|
5058
5248
|
return join(dirname(pkg), "assets");
|
|
@@ -5330,8 +5520,7 @@ function cmdAgents(rawArgv) {
|
|
|
5330
5520
|
}
|
|
5331
5521
|
const block = legacyBlock ?? renderAgentsBlock({
|
|
5332
5522
|
config: inputRel,
|
|
5333
|
-
skills: discoverBlockSkills(process.cwd())
|
|
5334
|
-
version: cliVersion()
|
|
5523
|
+
skills: discoverBlockSkills(process.cwd())
|
|
5335
5524
|
});
|
|
5336
5525
|
const outPath = resolve(values.out);
|
|
5337
5526
|
const existing = existsSync(outPath) ? readFileSync(outPath, "utf8") : void 0;
|