@getvitops/utils 8.0.1 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/ads/index.mjs +2 -1
- package/dist/ads/index.mjs.map +1 -1
- package/dist/{cloudflare-CgAsXiqe.mjs → cloudflare-CD8vwrgj.mjs} +2 -24
- package/dist/cloudflare-CD8vwrgj.mjs.map +1 -0
- package/dist/hosting/index.d.mts +184 -0
- package/dist/hosting/index.d.mts.map +1 -0
- package/dist/hosting/index.mjs +550 -0
- package/dist/hosting/index.mjs.map +1 -0
- package/dist/onboarding/index.mjs +2 -1
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-format-A18SvJ7C.mjs +24 -0
- package/dist/plan-format-A18SvJ7C.mjs.map +1 -0
- package/package.json +5 -1
- package/dist/cloudflare-CgAsXiqe.mjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @getvitops/utils
|
|
2
2
|
|
|
3
|
+
## 8.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **Added — `vitops init --site` and `vitops setup`, the scripted client-onboarding path.**
|
|
8
|
+
|
|
9
|
+
`vitops init --site [--name] [--url]` scaffolds a starter `site.json` (the umbrella `Config`,
|
|
10
|
+
embedding a starter design system at `designSystem.themes.default`) instead of a bare
|
|
11
|
+
`design-system.json` — exported as `defaultSiteConfig()` from `@getvitops/generator`, alongside a
|
|
12
|
+
new `CONFIG_SCHEMA_LOCAL_PATH` and an optional `$schema` field on `ConfigSchema` so a scaffolded
|
|
13
|
+
config gets editor autocomplete the same way `design-system.json` already does.
|
|
14
|
+
|
|
15
|
+
`vitops setup <repo|hosting|domain|search|ads|all>` (new `@getvitops/utils/hosting` subpath: a
|
|
16
|
+
pure planner + `wrangler`/`gh`-shelling executors, following the same idempotent
|
|
17
|
+
plan/observe/apply split as `onboarding`/`ads`) automates GitHub repo + branch policy and
|
|
18
|
+
Cloudflare dev D1/R2 + repo secrets, and plans/checks the workers.dev-vs-custom-domain
|
|
19
|
+
consistency without ever applying the cutover itself — see AGENTS.md's new "Client onboarding"
|
|
20
|
+
section for the full design rationale. `search`/`ads` forward to the existing `search setup`/
|
|
21
|
+
`ads setup` subcommands rather than reimplementing them.
|
|
22
|
+
|
|
23
|
+
No consumer-facing change in `@getvitops/vite` or `@getvitops/astro` in this release — bumped
|
|
24
|
+
only because they are in the `fixed` release group with `generator`/`utils`/`cli`.
|
|
25
|
+
`@getvitops/core` does have a real fix in this release; see the separate `banner-hidden-fix`
|
|
26
|
+
changeset.
|
|
27
|
+
|
|
3
28
|
## 8.0.1
|
|
4
29
|
|
|
5
30
|
## 8.0.0
|
package/dist/ads/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as table } from "../plan-format-A18SvJ7C.mjs";
|
|
2
|
+
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/ads/providers.ts
|
|
4
5
|
/** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */
|
package/dist/ads/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/ads/providers.ts","../../src/ads/plan.ts"],"sourcesContent":["/**\n * What each ad platform actually lets a build tool do — the table every `vitops ads`\n * subcommand reads.\n *\n * Written the way the indexing module was written: start from what the platforms\n * accept, because it is much narrower than it looks, and record the *absence* of a\n * path as a fact rather than leaving it as a gap.\n *\n * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,\n * Snapchat). That record is the one thing here that can be created for you.\n * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,\n * Microsoft Ads). Linking is the tag and the account id, nothing else. Those\n * resolve to a skip *plus* a named reason — a silent omission would read as\n * \"already done\".\n * - **No platform Marketing API is called.** Meta's Business Management\n * `verify_domain` needs a system-user token; the Google Ads API needs an\n * approved developer token and puts the consumer's own account on the line.\n * That is the same reasoning that keeps the Google Indexing API unwired: a\n * toolchain other people install must not ship a documented path to a terms\n * violation. The final \"Verify\" click stays a reminder.\n *\n * The verification token is **not a secret** — it is published in DNS, and the\n * platform fetching it back is the ownership proof, exactly like the IndexNow key.\n * So it lives in the config while credentials stay in the environment.\n *\n * Each tag is the platform's documented bootstrap reduced to its queue stub, with\n * the remote library on `data-src` for the consent gate to attach after the\n * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body\n * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a\n * snippet that creates its own `<script>`; those declare no `src`, and their body\n * stays inert until activation, so an undecided visitor still issues no request.\n */\nimport type { AdConsentCategory, AdProvider, AdPropertySetup } from './types.ts';\n\n/** How a platform proves you own the domain. */\nexport interface AdVerificationSpec {\n method: 'dns-txt' | 'none';\n /**\n * Prefix prepended to a bare token to form the record content. Owned here so a\n * config holds the token the platform shows, not a hand-assembled record — but\n * see `txtRecord`: a value already containing `=` passes through whole, which is\n * the escape hatch when a platform changes its spelling.\n */\n txtPrefix?: string;\n /** Where in the platform UI the token is found. Printed when asking for it. */\n where?: string;\n /** Why there is nothing to automate. Required when `method` is `none`. */\n reason?: string;\n}\n\n/** The tag, and which config field carries its id. */\nexport interface AdTagSpec {\n /** Which field the tag id comes from — and therefore what to ask for when absent. */\n needs: 'pixelId' | 'accountId';\n /** The remote library. Absent when the platform's own bootstrap loads itself. */\n src?: (id: string) => string;\n /** The inline bootstrap, which the gate runs before attaching `src`. */\n bootstrap: (id: string, entry: AdPropertySetup) => string;\n /**\n * Cookies the tag sets, for `data-consent-cookies` (what a revoke clears) and\n * for the generated cookie notice. A `*` suffix is a prefix match.\n */\n cookies: string[];\n /** The provider's own privacy policy, for the cookie notice. */\n privacyUrl: string;\n /** A visitor-facing opt-out, where the platform offers one. */\n optOut?: string;\n}\n\nexport interface AdPlatform {\n /** The platform as a reader would recognise it. */\n name: string;\n /**\n * The jurisdiction that can compel this platform to produce what it receives —\n * where the contracting entity is established. Feeds the generated privacy\n * policy's cross-border transfer disclosure, which is a claim about foreign\n * *access*, not foreign storage (see `Processor.operatorCountry` in the\n * generator's `legal/providers.ts`). A consumer whose contracting entity differs\n * declares their own processor entry instead.\n */\n operatorCountry: string;\n /** Click-ID parameters this platform stamps on an ad landing URL. */\n clickIdParams: string[];\n /** Where the operator manages the property. */\n consoleUrl: string;\n verification: AdVerificationSpec;\n tag: AdTagSpec;\n /** Follow-ups no API covers, given this entry. */\n manualSteps: (entry: AdPropertySetup) => string[];\n}\n\n/** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */\nconst gtagBootstrap = (id: string): string =>\n `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}` +\n `gtag('js',new Date());gtag('config','${id}');`;\n\n/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */\nconst queueStub = (global: string, methods: string): string =>\n `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};` +\n `window.${global}.methods=${methods};`;\n\nexport const AD_PLATFORMS: Record<AdProvider, AdPlatform> = {\n google: {\n name: 'Google Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['gclid', 'gbraid', 'wbraid'],\n consoleUrl: 'https://ads.google.com/',\n verification: {\n method: 'none',\n reason:\n 'Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)',\n },\n tag: {\n // The customer id (`123-456-7890`) is not the tag id: the conversion tag is\n // `AW-…`, which is why this asks for `pixelId` rather than reusing accountId.\n needs: 'pixelId',\n src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,\n bootstrap: gtagBootstrap,\n cookies: ['_gcl_au', '_gcl_aw', '_gcl_dc', '_gac_*'],\n privacyUrl: 'https://policies.google.com/privacy',\n optOut: 'https://adssettings.google.com/',\n },\n manualSteps: (e) => [\n ...(e.conversionLabel\n ? []\n : ['create a conversion action in Google Ads and record its label as `conversionLabel`']),\n 'link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions',\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n consoleUrl: 'https://business.facebook.com/settings/owned-domains',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'facebook-domain-verification=',\n where: 'Business settings → Brand safety → Domains → Add → DNS verification',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://connect.facebook.net/en_US/fbevents.js',\n bootstrap: (id) =>\n `${queueStub('fbq', `['init','track','trackCustom','consent']`)}` +\n `fbq('init','${id}');fbq('track','PageView');`,\n cookies: ['_fbp', '_fbc'],\n privacyUrl: 'https://www.facebook.com/privacy/policy/',\n optOut: 'https://www.facebook.com/adpreferences/ad_settings',\n },\n manualSteps: () => [\n 'click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called',\n ],\n },\n\n linkedin: {\n name: 'LinkedIn',\n operatorCountry: 'the United States',\n clickIdParams: ['li_fat_id'],\n consoleUrl: 'https://www.linkedin.com/campaignmanager/',\n verification: {\n method: 'none',\n reason:\n 'LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)',\n },\n tag: {\n needs: 'accountId',\n src: () => 'https://snap.licdn.com/li.lms-analytics/insight.min.js',\n bootstrap: (id) =>\n `window._linkedin_partner_id='${id}';` +\n `window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];` +\n `window._linkedin_data_partner_ids.push('${id}');`,\n cookies: ['li_sugr', 'li_gc', 'bcookie', 'lidc', 'UserMatchHistory'],\n privacyUrl: 'https://www.linkedin.com/legal/privacy-policy',\n optOut: 'https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out',\n },\n manualSteps: () => [\n 'add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions',\n ],\n },\n\n reddit: {\n name: 'Reddit',\n operatorCountry: 'the United States',\n clickIdParams: ['rdt_cid'],\n consoleUrl: 'https://ads.reddit.com/',\n verification: {\n method: 'none',\n reason: 'Reddit Ads has no domain verification — the Reddit Pixel is the link.',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://www.redditstatic.com/ads/pixel.js',\n bootstrap: (id) =>\n `${queueStub('rdt', `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,\n cookies: ['_rdt_uuid'],\n privacyUrl: 'https://www.reddit.com/policies/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n tiktok: {\n name: 'TikTok',\n operatorCountry: 'Singapore',\n clickIdParams: ['ttclid'],\n consoleUrl: 'https://ads.tiktok.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'tiktok-developers-site-verification=',\n where: 'Ads Manager → Assets → Events → Web events → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: (id) =>\n `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,\n bootstrap: (id) =>\n `window.TiktokAnalyticsObject='ttq';` +\n `${queueStub('ttq', `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}` +\n `ttq('init','${id}');ttq('page');`,\n cookies: ['_ttp'],\n privacyUrl: 'https://www.tiktok.com/legal/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n microsoft: {\n name: 'Microsoft Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['msclkid'],\n consoleUrl: 'https://ads.microsoft.com/',\n verification: {\n method: 'none',\n reason: 'Microsoft Ads has no domain verification — the UET tag is the link.',\n },\n tag: {\n needs: 'pixelId',\n // No `src`: UET must be constructed *after* bat.js evaluates, so the\n // platform's own snippet attaches an onload handler to a script it creates.\n // Inert until activation all the same — the body is never parsed before then.\n bootstrap: (id) =>\n `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};` +\n `o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,` +\n `n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},` +\n `i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,\n cookies: ['_uetsid', '_uetvid', 'MUID'],\n privacyUrl: 'https://privacy.microsoft.com/privacystatement',\n optOut: 'https://account.microsoft.com/privacy/ad-settings',\n },\n manualSteps: () => [],\n },\n\n pinterest: {\n name: 'Pinterest',\n operatorCountry: 'the United States',\n clickIdParams: ['epik'],\n consoleUrl: 'https://ads.pinterest.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'pinterest-site-verification=',\n where: 'Business hub → Claim → Websites → Claim → Add TXT record',\n },\n tag: {\n needs: 'pixelId',\n // Pinterest's stub creates the script itself once `pintrk('load')` runs.\n bootstrap: (id) =>\n `${queueStub('pintrk', `['load','page','track']`)}` +\n `!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';` +\n `var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();` +\n `pintrk('load','${id}');pintrk('page');`,\n cookies: ['_pinterest_ct_ua', '_pin_unauth', '_epik'],\n privacyUrl: 'https://policy.pinterest.com/privacy-policy',\n },\n manualSteps: () => [\n 'confirm the claim in Business hub → Claim once the TXT record has propagated',\n ],\n },\n\n snapchat: {\n name: 'Snapchat',\n operatorCountry: 'the United States',\n clickIdParams: ['ScCid'],\n consoleUrl: 'https://ads.snapchat.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'snap-domain-verification=',\n where: 'Business Manager → Public Profiles / Domains → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://sc-static.net/scevent.min.js',\n bootstrap: (id) =>\n `${queueStub('snaptr', `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,\n cookies: ['_scid', '_scid_r', 'sc_at'],\n privacyUrl: 'https://snap.com/privacy/privacy-policy',\n },\n manualSteps: () => [],\n },\n};\n\n/** Every provider, in table order — the one place iteration order is decided. */\nexport const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS) as AdProvider[];\n\n/** Whether a string names a known provider (for CLI arg validation). */\nexport const isAdProvider = (value: string): value is AdProvider => value in AD_PLATFORMS;\n\n/**\n * The DNS TXT record content for a token.\n *\n * A value already containing `=` is taken as the whole record and left alone. That\n * is the same escape hatch an icon name containing `:` gets: a platform can change\n * its prefix spelling without waiting on a toolchain release, and an operator who\n * pasted the full record from the UI gets what they meant rather than a\n * double-prefixed record that verifies against nothing.\n */\nexport function txtRecord(provider: AdProvider, token: string): string {\n const trimmed = token.trim();\n if (trimmed.includes('=')) return trimmed;\n return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ''}${trimmed}`;\n}\n\n/** The tag id for an entry, or undefined when the field it needs is unset. */\nexport function tagId(entry: AdPropertySetup): string | undefined {\n const field = AD_PLATFORMS[entry.provider].tag.needs;\n return entry[field];\n}\n\n/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */\nexport const categoryOf = (entry: AdPropertySetup): AdConsentCategory =>\n entry.category ?? 'marketing';\n\nconst escapeAttr = (value: string): string =>\n value.replace(/&/g, '&').replace(/\"/g, '"').replace(/</g, '<');\n\n/**\n * One provider's tag, rendered as a **consent-gated, inert** `<script>`.\n *\n * `type=\"text/plain\"` with the URL on `data-src` is what makes the gate real: the\n * browser neither parses the body nor fetches the library, so an undecided\n * visitor's page issues no third-party request. Never give one of these a live\n * `src` — a gate that instead asks a loaded tracker not to track is a promise.\n *\n * Returns null when the entry has no tag id, so a caller can report the gap rather\n * than emit a snippet that initialises `undefined`.\n */\nexport function renderTag(\n entry: AdPropertySetup,\n strategy: 'idle' | 'async' | 'interaction' = 'idle',\n): string | null {\n const platform = AD_PLATFORMS[entry.provider];\n const id = tagId(entry);\n if (!id) return null;\n const attrs = [\n 'type=\"text/plain\"',\n 'data-vitops-tag',\n `data-consent=\"${categoryOf(entry)}\"`,\n `data-strategy=\"${strategy}\"`,\n ...(platform.tag.src ? [`data-src=\"${escapeAttr(platform.tag.src(id))}\"`] : []),\n `data-consent-cookies=\"${escapeAttr(platform.tag.cookies.join(','))}\"`,\n ];\n return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\\n<script ${attrs.join(' ')}>${platform.tag.bootstrap(id, entry)}</script>`;\n}\n","/**\n * Every decision `vitops ads setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor gathers each domain's live DNS state and applies what it is told; this\n * module decides, from that state, what is left to do. The same split\n * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*\n * account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here — a step whose desired state already holds resolves to\n * `skip`, so a re-run of a linked property is all skips by construction rather than\n * by the executor checking twice.\n *\n * One thing this planner adds over the onboarding one: a step that cannot proceed\n * for want of a **config value** resolves to `blocked` carrying the field name in\n * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the\n * missing token and re-plan, while the planner itself stays pure and knows nothing\n * about a terminal. A blocked step is drift, never a skip — the first run of a\n * property whose token has not been fetched yet must not read as \"already linked\".\n */\nimport { AD_PLATFORMS, tagId, txtRecord } from './providers.ts';\nimport type { AdDomainState, AdPropertySetup, AdsConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision. `blocked` means the config is missing something. */\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\n/** A config field the operator can supply to unblock a step. */\nexport type MissingField = 'domainVerification' | 'accountId' | 'pixelId';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do, or what is missing. */\n detail: string;\n /** Set only on `blocked`: which config field would unblock it. */\n needs?: MissingField | undefined;\n}\n\n/** The steps for one ad property, decided against its live state. */\nexport interface AdPropertyPlan {\n provider: AdPropertySetup['provider'];\n /** The platform name, so a formatter needn't reach back into the table. */\n name: string;\n /** The domain being verified, when the platform verifies one. */\n domain?: string | undefined;\n /** Ensure the apex verification TXT. */\n txt: StepPlan;\n /** Whether the tag can be emitted (`ads tags`). */\n tag: StepPlan;\n /** The exact TXT content to create, when `txt.action` is `create`. */\n txtContent?: string | undefined;\n /** Manual follow-ups — including *why* a platform has no verification step. */\n reminders: string[];\n}\n\nexport interface AdsPlan {\n properties: AdPropertyPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/**\n * Decide one property's steps.\n *\n * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:\n * our record is present (skip), a *different* record from the same platform is\n * present (create, and say so — a token rotation leaves the old record in place,\n * since this command never edits or deletes DNS), or no token is configured yet\n * (blocked, naming the field).\n */\nexport function planProperty(setup: AdPropertySetup, state: AdDomainState): AdPropertyPlan {\n const platform = AD_PLATFORMS[setup.provider];\n const reminders = [...platform.manualSteps(setup)];\n\n let txt: StepPlan;\n let txtContent: string | undefined;\n\n if (platform.verification.method === 'none') {\n txt = { action: 'skip', detail: platform.verification.reason ?? 'no domain verification' };\n } else if (!setup.domainVerification) {\n txt = {\n action: 'blocked',\n detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,\n needs: 'domainVerification',\n };\n } else {\n const desired = txtRecord(setup.provider, setup.domainVerification);\n const prefix = platform.verification.txtPrefix;\n const stale =\n prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);\n txt = state.txtContents.includes(desired)\n ? { action: 'skip', detail: 'verification TXT already present' }\n : {\n action: 'create',\n detail: stale\n ? 'create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)'\n : 'create verification TXT',\n };\n if (txt.action === 'create') txtContent = desired;\n }\n\n const id = tagId(setup);\n const tag: StepPlan = id\n ? { action: 'skip', detail: `tag ready (${platform.tag.needs} ${id})` }\n : {\n action: 'blocked',\n detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,\n needs: platform.tag.needs,\n };\n\n return {\n provider: setup.provider,\n name: platform.name,\n ...(setup.domain ? { domain: setup.domain } : {}),\n txt,\n tag,\n ...(txtContent ? { txtContent } : {}),\n reminders,\n };\n}\n\n/** Plan every configured property. */\nexport function plan(config: AdsConfig, states: Map<string, AdDomainState>): AdsPlan {\n const notes: string[] = [];\n const properties = config.properties.map((setup) => {\n const key = setup.domain ?? '';\n const state = states.get(key);\n if (!state && AD_PLATFORMS[setup.provider].verification.method === 'dns-txt')\n // Should not happen — the CLI observes every verifiable domain before\n // planning — but a missing observation must not read as \"nothing to do\".\n notes.push(`${setup.provider}: no observed DNS state for ${key || '(no domain)'}`);\n return planProperty(setup, state ?? { txtContents: [] });\n });\n return { properties, notes };\n}\n\n/** Any step that isn't a skip — i.e. the live state or the config has drifted. */\nexport function hasDrift(p: AdsPlan): boolean {\n return p.properties.some((x) => x.txt.action !== 'skip' || x.tag.action !== 'skip');\n}\n\n/** Every field a run still needs, per provider — what the CLI asks for. */\nexport function missingFields(p: AdsPlan): { provider: string; needs: MissingField }[] {\n const out: { provider: string; needs: MissingField }[] = [];\n for (const x of p.properties) {\n for (const step of [x.txt, x.tag]) {\n if (step.action === 'blocked' && step.needs)\n out.push({ provider: x.provider, needs: step.needs });\n }\n }\n return out;\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: AdsPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const x of p.properties) {\n lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ''}`);\n lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);\n lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);\n for (const r of x.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one property's run, for the summary table. */\nexport interface AdPropertyResult {\n provider: string;\n domain: string;\n txt: 'created' | 'present' | 'blocked' | 'failed' | 'n/a';\n tag: 'ready' | 'blocked';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per property, plus any reminders. */\nexport function formatSummary(results: AdPropertyResult[]): string {\n const headers = ['PROVIDER', 'DOMAIN', 'VERIFY', 'TAG'];\n const rows = results.map((r) => [r.provider, r.domain, r.txt, r.tag]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));\n return table(headers, rows, reminders);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/ads/providers.ts","../../src/ads/plan.ts"],"sourcesContent":["/**\n * What each ad platform actually lets a build tool do — the table every `vitops ads`\n * subcommand reads.\n *\n * Written the way the indexing module was written: start from what the platforms\n * accept, because it is much narrower than it looks, and record the *absence* of a\n * path as a fact rather than leaving it as a gap.\n *\n * - **Four platforms verify a domain by DNS TXT** (Meta, TikTok, Pinterest,\n * Snapchat). That record is the one thing here that can be created for you.\n * - **Four have no domain verification at all** (Google Ads, LinkedIn, Reddit,\n * Microsoft Ads). Linking is the tag and the account id, nothing else. Those\n * resolve to a skip *plus* a named reason — a silent omission would read as\n * \"already done\".\n * - **No platform Marketing API is called.** Meta's Business Management\n * `verify_domain` needs a system-user token; the Google Ads API needs an\n * approved developer token and puts the consumer's own account on the line.\n * That is the same reasoning that keeps the Google Indexing API unwired: a\n * toolchain other people install must not ship a documented path to a terms\n * violation. The final \"Verify\" click stays a reminder.\n *\n * The verification token is **not a secret** — it is published in DNS, and the\n * platform fetching it back is the ownership proof, exactly like the IndexNow key.\n * So it lives in the config while credentials stay in the environment.\n *\n * Each tag is the platform's documented bootstrap reduced to its queue stub, with\n * the remote library on `data-src` for the consent gate to attach after the\n * bootstrap has run (`@getvitops/core`'s `activateScript` emits the inline body\n * first for exactly this reason). Two platforms — Microsoft and Pinterest — ship a\n * snippet that creates its own `<script>`; those declare no `src`, and their body\n * stays inert until activation, so an undecided visitor still issues no request.\n */\nimport type { AdConsentCategory, AdProvider, AdPropertySetup } from './types.ts';\n\n/** How a platform proves you own the domain. */\nexport interface AdVerificationSpec {\n method: 'dns-txt' | 'none';\n /**\n * Prefix prepended to a bare token to form the record content. Owned here so a\n * config holds the token the platform shows, not a hand-assembled record — but\n * see `txtRecord`: a value already containing `=` passes through whole, which is\n * the escape hatch when a platform changes its spelling.\n */\n txtPrefix?: string;\n /** Where in the platform UI the token is found. Printed when asking for it. */\n where?: string;\n /** Why there is nothing to automate. Required when `method` is `none`. */\n reason?: string;\n}\n\n/** The tag, and which config field carries its id. */\nexport interface AdTagSpec {\n /** Which field the tag id comes from — and therefore what to ask for when absent. */\n needs: 'pixelId' | 'accountId';\n /** The remote library. Absent when the platform's own bootstrap loads itself. */\n src?: (id: string) => string;\n /** The inline bootstrap, which the gate runs before attaching `src`. */\n bootstrap: (id: string, entry: AdPropertySetup) => string;\n /**\n * Cookies the tag sets, for `data-consent-cookies` (what a revoke clears) and\n * for the generated cookie notice. A `*` suffix is a prefix match.\n */\n cookies: string[];\n /** The provider's own privacy policy, for the cookie notice. */\n privacyUrl: string;\n /** A visitor-facing opt-out, where the platform offers one. */\n optOut?: string;\n}\n\nexport interface AdPlatform {\n /** The platform as a reader would recognise it. */\n name: string;\n /**\n * The jurisdiction that can compel this platform to produce what it receives —\n * where the contracting entity is established. Feeds the generated privacy\n * policy's cross-border transfer disclosure, which is a claim about foreign\n * *access*, not foreign storage (see `Processor.operatorCountry` in the\n * generator's `legal/providers.ts`). A consumer whose contracting entity differs\n * declares their own processor entry instead.\n */\n operatorCountry: string;\n /** Click-ID parameters this platform stamps on an ad landing URL. */\n clickIdParams: string[];\n /** Where the operator manages the property. */\n consoleUrl: string;\n verification: AdVerificationSpec;\n tag: AdTagSpec;\n /** Follow-ups no API covers, given this entry. */\n manualSteps: (entry: AdPropertySetup) => string[];\n}\n\n/** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */\nconst gtagBootstrap = (id: string): string =>\n `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}` +\n `gtag('js',new Date());gtag('config','${id}');`;\n\n/** The queue-stub shape shared by fbq/ttq/rdt/snaptr/pintrk. */\nconst queueStub = (global: string, methods: string): string =>\n `window.${global}=window.${global}||function(){(window.${global}.q=window.${global}.q||[]).push(arguments)};` +\n `window.${global}.methods=${methods};`;\n\nexport const AD_PLATFORMS: Record<AdProvider, AdPlatform> = {\n google: {\n name: 'Google Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['gclid', 'gbraid', 'wbraid'],\n consoleUrl: 'https://ads.google.com/',\n verification: {\n method: 'none',\n reason:\n 'Google Ads has no domain verification — the conversion tag is the link. (Merchant Center claims a website; that is a different product.)',\n },\n tag: {\n // The customer id (`123-456-7890`) is not the tag id: the conversion tag is\n // `AW-…`, which is why this asks for `pixelId` rather than reusing accountId.\n needs: 'pixelId',\n src: (id) => `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`,\n bootstrap: gtagBootstrap,\n cookies: ['_gcl_au', '_gcl_aw', '_gcl_dc', '_gac_*'],\n privacyUrl: 'https://policies.google.com/privacy',\n optOut: 'https://adssettings.google.com/',\n },\n manualSteps: (e) => [\n ...(e.conversionLabel\n ? []\n : ['create a conversion action in Google Ads and record its label as `conversionLabel`']),\n 'link the Google Ads account to Search Console / GA4 in the UI if you want imported conversions',\n ],\n },\n\n meta: {\n name: 'Meta',\n operatorCountry: 'the United States',\n clickIdParams: ['fbclid'],\n consoleUrl: 'https://business.facebook.com/settings/owned-domains',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'facebook-domain-verification=',\n where: 'Business settings → Brand safety → Domains → Add → DNS verification',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://connect.facebook.net/en_US/fbevents.js',\n bootstrap: (id) =>\n `${queueStub('fbq', `['init','track','trackCustom','consent']`)}` +\n `fbq('init','${id}');fbq('track','PageView');`,\n cookies: ['_fbp', '_fbc'],\n privacyUrl: 'https://www.facebook.com/privacy/policy/',\n optOut: 'https://www.facebook.com/adpreferences/ad_settings',\n },\n manualSteps: () => [\n 'click Verify on the domain in Business settings once the TXT record has propagated — the Business Management API needs a system-user token and is deliberately not called',\n ],\n },\n\n linkedin: {\n name: 'LinkedIn',\n operatorCountry: 'the United States',\n clickIdParams: ['li_fat_id'],\n consoleUrl: 'https://www.linkedin.com/campaignmanager/',\n verification: {\n method: 'none',\n reason:\n 'LinkedIn Ads has no domain verification — the Insight Tag is the link. (Verifying a LinkedIn *Page* is a separate, unrelated flow.)',\n },\n tag: {\n needs: 'accountId',\n src: () => 'https://snap.licdn.com/li.lms-analytics/insight.min.js',\n bootstrap: (id) =>\n `window._linkedin_partner_id='${id}';` +\n `window._linkedin_data_partner_ids=window._linkedin_data_partner_ids||[];` +\n `window._linkedin_data_partner_ids.push('${id}');`,\n cookies: ['li_sugr', 'li_gc', 'bcookie', 'lidc', 'UserMatchHistory'],\n privacyUrl: 'https://www.linkedin.com/legal/privacy-policy',\n optOut: 'https://www.linkedin.com/psettings/guest-controls/retargeting-opt-out',\n },\n manualSteps: () => [\n 'add conversions in Campaign Manager — the Insight Tag alone records page visits, not conversions',\n ],\n },\n\n reddit: {\n name: 'Reddit',\n operatorCountry: 'the United States',\n clickIdParams: ['rdt_cid'],\n consoleUrl: 'https://ads.reddit.com/',\n verification: {\n method: 'none',\n reason: 'Reddit Ads has no domain verification — the Reddit Pixel is the link.',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://www.redditstatic.com/ads/pixel.js',\n bootstrap: (id) =>\n `${queueStub('rdt', `['init','track']`)}rdt('init','${id}');rdt('track','PageVisit');`,\n cookies: ['_rdt_uuid'],\n privacyUrl: 'https://www.reddit.com/policies/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n tiktok: {\n name: 'TikTok',\n operatorCountry: 'Singapore',\n clickIdParams: ['ttclid'],\n consoleUrl: 'https://ads.tiktok.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'tiktok-developers-site-verification=',\n where: 'Ads Manager → Assets → Events → Web events → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: (id) =>\n `https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(id)}&lib=ttq`,\n bootstrap: (id) =>\n `window.TiktokAnalyticsObject='ttq';` +\n `${queueStub('ttq', `['page','track','identify','instances','debug','on','off','once','ready','alias','group','enableCookie','disableCookie']`)}` +\n `ttq('init','${id}');ttq('page');`,\n cookies: ['_ttp'],\n privacyUrl: 'https://www.tiktok.com/legal/privacy-policy',\n },\n manualSteps: () => [],\n },\n\n microsoft: {\n name: 'Microsoft Ads',\n operatorCountry: 'the United States',\n clickIdParams: ['msclkid'],\n consoleUrl: 'https://ads.microsoft.com/',\n verification: {\n method: 'none',\n reason: 'Microsoft Ads has no domain verification — the UET tag is the link.',\n },\n tag: {\n needs: 'pixelId',\n // No `src`: UET must be constructed *after* bat.js evaluates, so the\n // platform's own snippet attaches an onload handler to a script it creates.\n // Inert until activation all the same — the body is never parsed before then.\n bootstrap: (id) =>\n `(function(w,d,t,r,u){var f,n,i;w[u]=w[u]||[],f=function(){var o={ti:'${id}',enableAutoSpaTracking:true};` +\n `o.q=w[u],w[u]=new UET(o),w[u].push('pageLoad')},n=d.createElement(t),n.src=r,n.async=1,` +\n `n.onload=n.onreadystatechange=function(){var s=this.readyState;s&&s!=='loaded'&&s!=='complete'||(f(),n.onload=n.onreadystatechange=null)},` +\n `i=d.getElementsByTagName(t)[0],i.parentNode.insertBefore(n,i)})(window,document,'script','https://bat.bing.com/bat.js','uetq');`,\n cookies: ['_uetsid', '_uetvid', 'MUID'],\n privacyUrl: 'https://privacy.microsoft.com/privacystatement',\n optOut: 'https://account.microsoft.com/privacy/ad-settings',\n },\n manualSteps: () => [],\n },\n\n pinterest: {\n name: 'Pinterest',\n operatorCountry: 'the United States',\n clickIdParams: ['epik'],\n consoleUrl: 'https://ads.pinterest.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'pinterest-site-verification=',\n where: 'Business hub → Claim → Websites → Claim → Add TXT record',\n },\n tag: {\n needs: 'pixelId',\n // Pinterest's stub creates the script itself once `pintrk('load')` runs.\n bootstrap: (id) =>\n `${queueStub('pintrk', `['load','page','track']`)}` +\n `!function(){var t=document.createElement('script');t.async=!0,t.src='https://s.pinimg.com/ct/core.js';` +\n `var e=document.getElementsByTagName('script')[0];e.parentNode.insertBefore(t,e)}();` +\n `pintrk('load','${id}');pintrk('page');`,\n cookies: ['_pinterest_ct_ua', '_pin_unauth', '_epik'],\n privacyUrl: 'https://policy.pinterest.com/privacy-policy',\n },\n manualSteps: () => [\n 'confirm the claim in Business hub → Claim once the TXT record has propagated',\n ],\n },\n\n snapchat: {\n name: 'Snapchat',\n operatorCountry: 'the United States',\n clickIdParams: ['ScCid'],\n consoleUrl: 'https://ads.snapchat.com/',\n verification: {\n method: 'dns-txt',\n txtPrefix: 'snap-domain-verification=',\n where: 'Business Manager → Public Profiles / Domains → Verify domain → DNS',\n },\n tag: {\n needs: 'pixelId',\n src: () => 'https://sc-static.net/scevent.min.js',\n bootstrap: (id) =>\n `${queueStub('snaptr', `['init','track']`)}snaptr('init','${id}');snaptr('track','PAGE_VIEW');`,\n cookies: ['_scid', '_scid_r', 'sc_at'],\n privacyUrl: 'https://snap.com/privacy/privacy-policy',\n },\n manualSteps: () => [],\n },\n};\n\n/** Every provider, in table order — the one place iteration order is decided. */\nexport const AD_PROVIDER_KEYS = Object.keys(AD_PLATFORMS) as AdProvider[];\n\n/** Whether a string names a known provider (for CLI arg validation). */\nexport const isAdProvider = (value: string): value is AdProvider => value in AD_PLATFORMS;\n\n/**\n * The DNS TXT record content for a token.\n *\n * A value already containing `=` is taken as the whole record and left alone. That\n * is the same escape hatch an icon name containing `:` gets: a platform can change\n * its prefix spelling without waiting on a toolchain release, and an operator who\n * pasted the full record from the UI gets what they meant rather than a\n * double-prefixed record that verifies against nothing.\n */\nexport function txtRecord(provider: AdProvider, token: string): string {\n const trimmed = token.trim();\n if (trimmed.includes('=')) return trimmed;\n return `${AD_PLATFORMS[provider].verification.txtPrefix ?? ''}${trimmed}`;\n}\n\n/** The tag id for an entry, or undefined when the field it needs is unset. */\nexport function tagId(entry: AdPropertySetup): string | undefined {\n const field = AD_PLATFORMS[entry.provider].tag.needs;\n return entry[field];\n}\n\n/** The consent category gating an entry's tag. `marketing` unless stated otherwise. */\nexport const categoryOf = (entry: AdPropertySetup): AdConsentCategory =>\n entry.category ?? 'marketing';\n\nconst escapeAttr = (value: string): string =>\n value.replace(/&/g, '&').replace(/\"/g, '"').replace(/</g, '<');\n\n/**\n * One provider's tag, rendered as a **consent-gated, inert** `<script>`.\n *\n * `type=\"text/plain\"` with the URL on `data-src` is what makes the gate real: the\n * browser neither parses the body nor fetches the library, so an undecided\n * visitor's page issues no third-party request. Never give one of these a live\n * `src` — a gate that instead asks a loaded tracker not to track is a promise.\n *\n * Returns null when the entry has no tag id, so a caller can report the gap rather\n * than emit a snippet that initialises `undefined`.\n */\nexport function renderTag(\n entry: AdPropertySetup,\n strategy: 'idle' | 'async' | 'interaction' = 'idle',\n): string | null {\n const platform = AD_PLATFORMS[entry.provider];\n const id = tagId(entry);\n if (!id) return null;\n const attrs = [\n 'type=\"text/plain\"',\n 'data-vitops-tag',\n `data-consent=\"${categoryOf(entry)}\"`,\n `data-strategy=\"${strategy}\"`,\n ...(platform.tag.src ? [`data-src=\"${escapeAttr(platform.tag.src(id))}\"`] : []),\n `data-consent-cookies=\"${escapeAttr(platform.tag.cookies.join(','))}\"`,\n ];\n return `<!-- ${platform.name} — gated on ${categoryOf(entry)} consent -->\\n<script ${attrs.join(' ')}>${platform.tag.bootstrap(id, entry)}</script>`;\n}\n","/**\n * Every decision `vitops ads setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock, no stdin. The\n * executor gathers each domain's live DNS state and applies what it is told; this\n * module decides, from that state, what is left to do. The same split\n * `onboarding/plan.ts` makes, for the same two payoffs: `--dry` is a *complete*\n * account of a run, and `--check` is an honest drift report.\n *\n * Idempotency lives here — a step whose desired state already holds resolves to\n * `skip`, so a re-run of a linked property is all skips by construction rather than\n * by the executor checking twice.\n *\n * One thing this planner adds over the onboarding one: a step that cannot proceed\n * for want of a **config value** resolves to `blocked` carrying the field name in\n * `needs`. That is a machine-readable gap, which is what lets the CLI *ask* for the\n * missing token and re-plan, while the planner itself stays pure and knows nothing\n * about a terminal. A blocked step is drift, never a skip — the first run of a\n * property whose token has not been fetched yet must not read as \"already linked\".\n */\nimport { AD_PLATFORMS, tagId, txtRecord } from './providers.ts';\nimport type { AdDomainState, AdPropertySetup, AdsConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision. `blocked` means the config is missing something. */\nexport type StepAction = 'create' | 'skip' | 'blocked';\n\n/** A config field the operator can supply to unblock a step. */\nexport type MissingField = 'domainVerification' | 'accountId' | 'pixelId';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do, or what is missing. */\n detail: string;\n /** Set only on `blocked`: which config field would unblock it. */\n needs?: MissingField | undefined;\n}\n\n/** The steps for one ad property, decided against its live state. */\nexport interface AdPropertyPlan {\n provider: AdPropertySetup['provider'];\n /** The platform name, so a formatter needn't reach back into the table. */\n name: string;\n /** The domain being verified, when the platform verifies one. */\n domain?: string | undefined;\n /** Ensure the apex verification TXT. */\n txt: StepPlan;\n /** Whether the tag can be emitted (`ads tags`). */\n tag: StepPlan;\n /** The exact TXT content to create, when `txt.action` is `create`. */\n txtContent?: string | undefined;\n /** Manual follow-ups — including *why* a platform has no verification step. */\n reminders: string[];\n}\n\nexport interface AdsPlan {\n properties: AdPropertyPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/**\n * Decide one property's steps.\n *\n * The `dns-txt` branch has three outcomes, and keeping them distinct is the point:\n * our record is present (skip), a *different* record from the same platform is\n * present (create, and say so — a token rotation leaves the old record in place,\n * since this command never edits or deletes DNS), or no token is configured yet\n * (blocked, naming the field).\n */\nexport function planProperty(setup: AdPropertySetup, state: AdDomainState): AdPropertyPlan {\n const platform = AD_PLATFORMS[setup.provider];\n const reminders = [...platform.manualSteps(setup)];\n\n let txt: StepPlan;\n let txtContent: string | undefined;\n\n if (platform.verification.method === 'none') {\n txt = { action: 'skip', detail: platform.verification.reason ?? 'no domain verification' };\n } else if (!setup.domainVerification) {\n txt = {\n action: 'blocked',\n detail: `needs the verification token from ${platform.verification.where ?? platform.consoleUrl}`,\n needs: 'domainVerification',\n };\n } else {\n const desired = txtRecord(setup.provider, setup.domainVerification);\n const prefix = platform.verification.txtPrefix;\n const stale =\n prefix != null && state.txtContents.some((c) => c.startsWith(prefix) && c !== desired);\n txt = state.txtContents.includes(desired)\n ? { action: 'skip', detail: 'verification TXT already present' }\n : {\n action: 'create',\n detail: stale\n ? 'create verification TXT (an older token from this platform is still present — remove it yourself; this command never deletes DNS)'\n : 'create verification TXT',\n };\n if (txt.action === 'create') txtContent = desired;\n }\n\n const id = tagId(setup);\n const tag: StepPlan = id\n ? { action: 'skip', detail: `tag ready (${platform.tag.needs} ${id})` }\n : {\n action: 'blocked',\n detail: `needs ${platform.tag.needs} from ${platform.consoleUrl} to emit the tag`,\n needs: platform.tag.needs,\n };\n\n return {\n provider: setup.provider,\n name: platform.name,\n ...(setup.domain ? { domain: setup.domain } : {}),\n txt,\n tag,\n ...(txtContent ? { txtContent } : {}),\n reminders,\n };\n}\n\n/** Plan every configured property. */\nexport function plan(config: AdsConfig, states: Map<string, AdDomainState>): AdsPlan {\n const notes: string[] = [];\n const properties = config.properties.map((setup) => {\n const key = setup.domain ?? '';\n const state = states.get(key);\n if (!state && AD_PLATFORMS[setup.provider].verification.method === 'dns-txt')\n // Should not happen — the CLI observes every verifiable domain before\n // planning — but a missing observation must not read as \"nothing to do\".\n notes.push(`${setup.provider}: no observed DNS state for ${key || '(no domain)'}`);\n return planProperty(setup, state ?? { txtContents: [] });\n });\n return { properties, notes };\n}\n\n/** Any step that isn't a skip — i.e. the live state or the config has drifted. */\nexport function hasDrift(p: AdsPlan): boolean {\n return p.properties.some((x) => x.txt.action !== 'skip' || x.tag.action !== 'skip');\n}\n\n/** Every field a run still needs, per provider — what the CLI asks for. */\nexport function missingFields(p: AdsPlan): { provider: string; needs: MissingField }[] {\n const out: { provider: string; needs: MissingField }[] = [];\n for (const x of p.properties) {\n for (const step of [x.txt, x.tag]) {\n if (step.action === 'blocked' && step.needs)\n out.push({ provider: x.provider, needs: step.needs });\n }\n }\n return out;\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: AdsPlan): string {\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : '+');\n const lines: string[] = [];\n for (const x of p.properties) {\n lines.push(`${x.provider}${x.domain ? ` (${x.domain})` : ''}`);\n lines.push(` ${glyph(x.txt)} verify ${x.txt.detail}`);\n lines.push(` ${glyph(x.tag)} tag ${x.tag.detail}`);\n for (const r of x.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one property's run, for the summary table. */\nexport interface AdPropertyResult {\n provider: string;\n domain: string;\n txt: 'created' | 'present' | 'blocked' | 'failed' | 'n/a';\n tag: 'ready' | 'blocked';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per property, plus any reminders. */\nexport function formatSummary(results: AdPropertyResult[]): string {\n const headers = ['PROVIDER', 'DOMAIN', 'VERIFY', 'TAG'];\n const rows = results.map((r) => [r.provider, r.domain, r.txt, r.tag]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.provider}: ${m}`));\n return table(headers, rows, reminders);\n}\n"],"mappings":"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"}
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
//#region src/plan-format.ts
|
|
2
|
-
/**
|
|
3
|
-
* The end-of-run summary table shared by `onboarding/plan.ts`'s and `ads/plan.ts`'s
|
|
4
|
-
* `formatSummary` — same header row, same column padding, same "Reminders
|
|
5
|
-
* (manual — no API):" tail. Their `StepAction`/`StepPlan`/`formatPlan` glyph
|
|
6
|
-
* renderers stay separate: onboarding's steps are `create | update | skip`
|
|
7
|
-
* with no `blocked` state, ads' are `create | skip | blocked` with a `needs`
|
|
8
|
-
* field, and collapsing those two shapes into one would blur a real
|
|
9
|
-
* difference rather than remove duplication.
|
|
10
|
-
*/
|
|
11
|
-
const pad = (s, n) => s.padEnd(n);
|
|
12
|
-
/** Render a fixed-width table, then a "Reminders" section if any were passed. */
|
|
13
|
-
function table(headers, rows, reminders) {
|
|
14
|
-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
15
|
-
const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
|
|
16
|
-
const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
|
|
17
|
-
for (const row of rows) out.push(line(row));
|
|
18
|
-
if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
|
|
19
|
-
return out.join("\n");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
//#endregion
|
|
23
1
|
//#region src/onboarding/cloudflare.ts
|
|
24
2
|
/**
|
|
25
3
|
* Cloudflare DNS — the one thing this command writes.
|
|
@@ -110,5 +88,5 @@ async function createApexTxt(token, zoneId, domain, content, fetchImpl = fetch)
|
|
|
110
88
|
}
|
|
111
89
|
|
|
112
90
|
//#endregion
|
|
113
|
-
export {
|
|
114
|
-
//# sourceMappingURL=cloudflare-
|
|
91
|
+
export { findZoneId as n, listApexTxt as r, createApexTxt as t };
|
|
92
|
+
//# sourceMappingURL=cloudflare-CD8vwrgj.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloudflare-CD8vwrgj.mjs","names":[],"sources":["../src/onboarding/cloudflare.ts"],"sourcesContent":["/**\n * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (!zone)\n return {\n ok: false,\n status: res.status,\n // Name the scope: this lookup is a GET /zones?name=, which needs\n // `Zone:Read` — a token carrying only `Zone:DNS:Edit` reaches here and\n // reads as \"the zone isn't in this account\", which sends you to the\n // wrong dashboard.\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The \"Edit zone DNS\" template covers both.)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EAKZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
//#region src/hosting/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `vitops setup`'s own option surface.
|
|
4
|
+
*
|
|
5
|
+
* Structural, not imported from `@getvitops/generator`, for the same reason as
|
|
6
|
+
* `onboarding/types.ts` and `ads/types.ts`: the generator already depends on this
|
|
7
|
+
* package, so importing back would be a cycle. `exactOptionalPropertyTypes` is on,
|
|
8
|
+
* so optional fields are `?: T | undefined` throughout — these describe a *parsed
|
|
9
|
+
* JSON config*, where an absent key and an explicit `undefined` are the same thing.
|
|
10
|
+
*/
|
|
11
|
+
/** What the operator wants provisioned, derived from the config + CLI flags. */
|
|
12
|
+
interface HostingSetup {
|
|
13
|
+
platform: 'cloudflare' | 'node';
|
|
14
|
+
/** Repo name (also the D1/R2 name prefix), derived from `package.json`'s name. */
|
|
15
|
+
projectName: string;
|
|
16
|
+
repoVisibility: 'public' | 'private';
|
|
17
|
+
/** Whether the isolated dev D1 + R2 should be provisioned (cloudflare only). */
|
|
18
|
+
devResources: boolean;
|
|
19
|
+
/** GitHub `owner/repo`, once known — absent before `repo` has run. */
|
|
20
|
+
repoNwo?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Everything gathered from the live GitHub + Cloudflare state before planning. */
|
|
23
|
+
interface HostingState {
|
|
24
|
+
wranglerAuthed: boolean;
|
|
25
|
+
accountId?: string | undefined;
|
|
26
|
+
d1Present: boolean;
|
|
27
|
+
d1Id?: string | undefined;
|
|
28
|
+
r2Present: boolean;
|
|
29
|
+
ghAuthed: boolean;
|
|
30
|
+
originRemote?: string | undefined;
|
|
31
|
+
repoNwo?: string | undefined;
|
|
32
|
+
branches: string[];
|
|
33
|
+
defaultBranch?: string | undefined;
|
|
34
|
+
mainProtected: boolean;
|
|
35
|
+
/** `production`/`dev` environment names already present on the repo. */
|
|
36
|
+
environments: string[];
|
|
37
|
+
/** Repo secret names already set (values are never readable via the API). */
|
|
38
|
+
secretsPresent: string[];
|
|
39
|
+
}
|
|
40
|
+
/** One domain-consistency observation — read from `wrangler.jsonc` + the config. */
|
|
41
|
+
interface DomainState {
|
|
42
|
+
wranglerDev: boolean;
|
|
43
|
+
customDomain?: string | undefined;
|
|
44
|
+
/** `site.environments.<env>.url`, as configured. */
|
|
45
|
+
configuredUrl?: string | undefined;
|
|
46
|
+
/** `site.domains.canonical`, as configured. */
|
|
47
|
+
canonical?: string | undefined;
|
|
48
|
+
robots?: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/hosting/plan.d.ts
|
|
52
|
+
type StepAction = 'create' | 'update' | 'skip' | 'blocked';
|
|
53
|
+
interface StepPlan {
|
|
54
|
+
action: StepAction;
|
|
55
|
+
detail: string;
|
|
56
|
+
}
|
|
57
|
+
interface HostingPlan {
|
|
58
|
+
repo: StepPlan;
|
|
59
|
+
branches: StepPlan;
|
|
60
|
+
defaultBranch: StepPlan;
|
|
61
|
+
mainProtection: StepPlan;
|
|
62
|
+
prodEnvironment: StepPlan;
|
|
63
|
+
devEnvironment: StepPlan;
|
|
64
|
+
d1: StepPlan;
|
|
65
|
+
r2: StepPlan;
|
|
66
|
+
/** One step per repo secret this command sets. */
|
|
67
|
+
secrets: Record<string, StepPlan>;
|
|
68
|
+
notes: string[];
|
|
69
|
+
}
|
|
70
|
+
/** Plan the whole hosting sequence from setup + observed state. */
|
|
71
|
+
declare function plan(setup: HostingSetup, state: HostingState): HostingPlan;
|
|
72
|
+
/** Any step that isn't a skip — i.e. the live state has drifted from desired. */
|
|
73
|
+
declare function hasDrift(p: HostingPlan): boolean;
|
|
74
|
+
/** Every blocked step, with a name — what `--check` reports and the CLI surfaces. */
|
|
75
|
+
declare function blockedSteps(p: HostingPlan): {
|
|
76
|
+
name: string;
|
|
77
|
+
detail: string;
|
|
78
|
+
}[];
|
|
79
|
+
/** Render a plan as the block `--dry` and `--check` print. */
|
|
80
|
+
declare function formatPlan(p: HostingPlan): string;
|
|
81
|
+
/** The outcome of one run, for the summary table. */
|
|
82
|
+
interface HostingResult {
|
|
83
|
+
step: string;
|
|
84
|
+
status: 'created' | 'updated' | 'present' | 'blocked' | 'failed';
|
|
85
|
+
detail: string;
|
|
86
|
+
}
|
|
87
|
+
/** Render the end-of-run summary: one row per step, plus blocked reminders. */
|
|
88
|
+
declare function formatSummary(results: HostingResult[]): string;
|
|
89
|
+
/**
|
|
90
|
+
* Plan the domain-consistency check on its own — it has no executor, only an
|
|
91
|
+
* observation (`wrangler.jsonc` + the config), and it is never applied by this
|
|
92
|
+
* command. See `SETUP.html`'s "Domain strategy" stage: the actual cutover is a
|
|
93
|
+
* human, deliberate action.
|
|
94
|
+
*/
|
|
95
|
+
declare function planDomainConsistency(state: DomainState): StepPlan;
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/hosting/wrangler.d.ts
|
|
98
|
+
interface ExecResult {
|
|
99
|
+
ok: boolean;
|
|
100
|
+
stdout: string;
|
|
101
|
+
message?: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
type ExecImpl = (cmd: string, args: string[]) => Promise<ExecResult>;
|
|
104
|
+
/** The real executor — `execFile` with an argv array, never a shell string. */
|
|
105
|
+
declare const realExec: ExecImpl;
|
|
106
|
+
/** `wrangler whoami` — auth check + the 32-hex account id, from one call. */
|
|
107
|
+
declare function wranglerWhoami(execImpl?: ExecImpl): Promise<{
|
|
108
|
+
authed: boolean;
|
|
109
|
+
accountId?: string;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Find an existing dev D1 database's id by name.
|
|
113
|
+
*
|
|
114
|
+
* `d1 create`'s own stdout carries the id, but its format has drifted across
|
|
115
|
+
* wrangler versions before — a re-list is the one thing guaranteed to still hold
|
|
116
|
+
* the answer, so both the "does it exist" check and the post-create fallback go
|
|
117
|
+
* through this.
|
|
118
|
+
*/
|
|
119
|
+
declare function findD1(name: string, execImpl?: ExecImpl): Promise<string | undefined>;
|
|
120
|
+
/** Create a dev D1 database, then resolve its id via `findD1` (see its docblock). */
|
|
121
|
+
declare function createD1(name: string, execImpl?: ExecImpl): Promise<{
|
|
122
|
+
ok: boolean;
|
|
123
|
+
id?: string | undefined;
|
|
124
|
+
message?: string | undefined;
|
|
125
|
+
}>;
|
|
126
|
+
declare function findR2Bucket(name: string, execImpl?: ExecImpl): Promise<boolean>;
|
|
127
|
+
declare function createR2Bucket(name: string, execImpl?: ExecImpl): Promise<{
|
|
128
|
+
ok: boolean;
|
|
129
|
+
message?: string | undefined;
|
|
130
|
+
}>;
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/hosting/github.d.ts
|
|
133
|
+
declare function ghAuthed(execImpl?: ExecImpl): Promise<boolean>;
|
|
134
|
+
declare function originRemote(execImpl?: ExecImpl): Promise<string | undefined>;
|
|
135
|
+
declare function repoNwo(execImpl?: ExecImpl): Promise<string | undefined>;
|
|
136
|
+
declare function branches(execImpl?: ExecImpl): Promise<string[]>;
|
|
137
|
+
declare function defaultBranch(nwo: string, execImpl?: ExecImpl): Promise<string | undefined>;
|
|
138
|
+
declare function mainProtected(nwo: string, execImpl?: ExecImpl): Promise<boolean>;
|
|
139
|
+
declare function environments(nwo: string, execImpl?: ExecImpl): Promise<string[]>;
|
|
140
|
+
declare function secretsPresent(nwo: string, execImpl?: ExecImpl): Promise<string[]>;
|
|
141
|
+
declare function createRepo(name: string, visibility: 'public' | 'private', execImpl?: ExecImpl): Promise<{
|
|
142
|
+
ok: boolean;
|
|
143
|
+
message?: string | undefined;
|
|
144
|
+
}>;
|
|
145
|
+
declare function pushBranches(execImpl?: ExecImpl): Promise<{
|
|
146
|
+
ok: boolean;
|
|
147
|
+
message?: string | undefined;
|
|
148
|
+
}>;
|
|
149
|
+
declare function setDefaultBranch(nwo: string, branch: string, execImpl?: ExecImpl): Promise<{
|
|
150
|
+
ok: boolean;
|
|
151
|
+
message?: string | undefined;
|
|
152
|
+
}>;
|
|
153
|
+
/**
|
|
154
|
+
* `PUT .../branches/main/protection`. A 403 on Pro/Team-gated repos is expected.
|
|
155
|
+
*
|
|
156
|
+
* Every field goes through `gh api`'s `-f`/`-F` form flags rather than a JSON body
|
|
157
|
+
* on stdin — `gh api` supports nested keys (`a[b]=c`) and repeated array keys
|
|
158
|
+
* (`a[]=x`) this way, so no request needs a second I/O channel `ExecImpl` would
|
|
159
|
+
* have to carry just for this one call.
|
|
160
|
+
*/
|
|
161
|
+
declare function protectMain(nwo: string, execImpl?: ExecImpl): Promise<{
|
|
162
|
+
ok: boolean;
|
|
163
|
+
message?: string | undefined;
|
|
164
|
+
}>;
|
|
165
|
+
/** The repo's admin collaborators — numeric user ids, which is what the
|
|
166
|
+
* environment-reviewers API wants (not logins). */
|
|
167
|
+
declare function adminCollaboratorIds(nwo: string, execImpl?: ExecImpl): Promise<number[]>;
|
|
168
|
+
/** `PUT .../environments/production`, reviewers = current admin collaborators. */
|
|
169
|
+
declare function createProdEnvironment(nwo: string, reviewerUserIds: number[], execImpl?: ExecImpl): Promise<{
|
|
170
|
+
ok: boolean;
|
|
171
|
+
message?: string | undefined;
|
|
172
|
+
}>;
|
|
173
|
+
/** `PUT .../environments/dev` with no reviewers — deliberately left open. */
|
|
174
|
+
declare function createDevEnvironment(nwo: string, execImpl?: ExecImpl): Promise<{
|
|
175
|
+
ok: boolean;
|
|
176
|
+
message?: string | undefined;
|
|
177
|
+
}>;
|
|
178
|
+
declare function setSecret(name: string, value: string, execImpl?: ExecImpl): Promise<{
|
|
179
|
+
ok: boolean;
|
|
180
|
+
message?: string | undefined;
|
|
181
|
+
}>;
|
|
182
|
+
//#endregion
|
|
183
|
+
export { type DomainState, type ExecImpl, type ExecResult, type HostingPlan, type HostingResult, type HostingSetup, type HostingState, type StepAction, type StepPlan, adminCollaboratorIds, blockedSteps, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan, formatSummary, ghAuthed, hasDrift, mainProtected, originRemote, plan, planDomainConsistency, protectMain, pushBranches, realExec, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami };
|
|
184
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/hosting/types.ts","../../src/hosting/plan.ts","../../src/hosting/wrangler.ts","../../src/hosting/github.ts"],"mappings":";;AAWA;;;;;;;;;UAAiB,YAAA;EACf,QAAA;EAWe;EATf,WAAA;EACA,cAAA;EAQ2B;EAN3B,YAAA;EAQA;EANA,OAAA;AAAA;;UAIe,YAAA;EACf,cAAA;EACA,SAAA;EACA,SAAA;EACA,IAAA;EACA,SAAA;EACA,QAAA;EACA,YAAA;EACA,OAAA;EACA,QAAA;EACA,aAAA;EACA,aAAA;;EAEA,YAAA;EAOA;EALA,cAAA;AAAA;;UAIe,WAAA;EACf,WAAA;EACA,YAAA;;EAEA,aAAA;;EAEA,SAAA;EACA,MAAA;AAAA;;;KC1BU,UAAA;AAAA,UAEK,QAAA;EACf,MAAA,EAAQ,UAAU;EAClB,MAAA;AAAA;AAAA,UAGe,WAAA;EACf,IAAA,EAAM,QAAA;EACN,QAAA,EAAU,QAAA;EACV,aAAA,EAAe,QAAA;EACf,cAAA,EAAgB,QAAA;EAChB,eAAA,EAAiB,QAAA;EACjB,cAAA,EAAgB,QAAA;EAChB,EAAA,EAAI,QAAA;EACJ,EAAA,EAAI,QAAA;EDfuB;ECiB3B,OAAA,EAAS,MAAA,SAAe,QAAA;EACxB,KAAA;AAAA;;iBASc,IAAA,CAAK,KAAA,EAAO,YAAA,EAAc,KAAA,EAAO,YAAA,GAAe,WAAA;;iBA6HhD,QAAA,CAAS,CAAc,EAAX,WAAW;;iBAKvB,YAAA,CAAa,CAAA,EAAG,WAAW;EAAK,IAAA;EAAc,MAAA;AAAA;;iBAkB9C,UAAA,CAAW,CAAc,EAAX,WAAW;ADhKzB;AAAA,UCmLC,aAAA;EACf,IAAA;EACA,MAAA;EACA,MAAA;AAAA;;iBAIc,aAAA,CAAc,OAAwB,EAAf,aAAa;;;;AD/K5C;;;iBC8LQ,qBAAA,CAAsB,KAAA,EAAO,WAAA,GAAc,QAAQ;;;UC/NlD,UAAA;EACf,EAAA;EACA,MAAA;EACA,OAAA;AAAA;AAAA,KAGU,QAAA,IAAY,GAAA,UAAa,IAAA,eAAmB,OAAO,CAAC,UAAA;;cAGnD,QAAA,EAAU,QAYtB;;iBAGqB,cAAA,CACpB,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,MAAA;EAAiB,SAAA;AAAA;;;;;;;;;iBAeR,MAAA,CACpB,IAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;;iBAYY,QAAA,CACpB,IAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,EAAA;EAAyB,OAAA;AAAA;AAAA,iBAS7B,YAAA,CAAa,IAAA,UAAc,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAMlE,cAAA,CACpB,IAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;;;iBC/EJ,QAAA,CAAS,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAIhD,YAAA,CAAa,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAKpD,OAAA,CAAQ,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAU/C,QAAA,CAAS,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAShD,aAAA,CACpB,GAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;AAAA,iBAWY,aAAA,CAAc,GAAA,UAAa,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAIlE,YAAA,CAAa,GAAA,UAAa,QAAA,GAAU,QAAA,GAAsB,OAAO;AAAA,iBAYjE,cAAA,CACpB,GAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;AAAA,iBAcY,UAAA,CACpB,IAAA,UACA,UAAA,wBACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;AAAA,iBAYJ,YAAA,CACpB,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;AAAA,iBAKJ,gBAAA,CACpB,GAAA,UACA,MAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;;;;;;;;;iBAaJ,WAAA,CACpB,GAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;;AF7G1B;iBEuIsB,oBAAA,CACpB,GAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;;iBAWY,qBAAA,CACpB,GAAA,UACA,eAAA,YACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;;iBAqBJ,oBAAA,CACpB,GAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA;AAAA,iBAKJ,SAAA,CACpB,IAAA,UACA,KAAA,UACA,QAAA,GAAU,QAAA,GACT,OAAO;EAAG,EAAA;EAAa,OAAA;AAAA"}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { t as table } from "../plan-format-A18SvJ7C.mjs";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
//#region src/hosting/plan.ts
|
|
6
|
+
/**
|
|
7
|
+
* Every decision `vitops setup` makes, as one pure function.
|
|
8
|
+
*
|
|
9
|
+
* Same split as `onboarding/plan.ts` and `ads/plan.ts`: this module decides, from
|
|
10
|
+
* observed live state, what's left to do; `wrangler.ts`/`github.ts` execute a plan
|
|
11
|
+
* and decide nothing. That is what makes `--dry` a complete account of a run and
|
|
12
|
+
* `--check` an honest drift report with no network beyond the observation step.
|
|
13
|
+
*
|
|
14
|
+
* Idempotency lives here: a step whose desired state already holds resolves to
|
|
15
|
+
* `skip`, so a re-run of a fully-provisioned project is all skips by construction.
|
|
16
|
+
*
|
|
17
|
+
* `blocked` (ads' addition to onboarding's three-state `StepAction`) covers the one
|
|
18
|
+
* thing this command genuinely cannot do: mint a Cloudflare API token. That is
|
|
19
|
+
* config nothing here can supply — surfaced as blocked, carrying the manual path,
|
|
20
|
+
* never as a failure.
|
|
21
|
+
*
|
|
22
|
+
* Never destructive: no step here deletes a database, a bucket, a branch, a
|
|
23
|
+
* protection rule, or a DNS record — matching `onboarding/cloudflare.ts`'s DNS
|
|
24
|
+
* verbs, which have no update or delete either.
|
|
25
|
+
*/
|
|
26
|
+
const SECRETS_FOR = (setup) => setup.platform === "cloudflare" ? [
|
|
27
|
+
"DEV_D1_ID",
|
|
28
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
29
|
+
"CLOUDFLARE_API_TOKEN"
|
|
30
|
+
] : [];
|
|
31
|
+
/** Plan the whole hosting sequence from setup + observed state. */
|
|
32
|
+
function plan(setup, state) {
|
|
33
|
+
const notes = [];
|
|
34
|
+
const repo = state.originRemote && state.repoNwo ? {
|
|
35
|
+
action: "skip",
|
|
36
|
+
detail: `repo already exists (${state.repoNwo})`
|
|
37
|
+
} : {
|
|
38
|
+
action: "create",
|
|
39
|
+
detail: `create GitHub repo "${setup.projectName}" (${setup.repoVisibility})`
|
|
40
|
+
};
|
|
41
|
+
const branches = state.branches.includes("main") && state.branches.includes("dev") ? {
|
|
42
|
+
action: "skip",
|
|
43
|
+
detail: "main + dev already exist"
|
|
44
|
+
} : {
|
|
45
|
+
action: "create",
|
|
46
|
+
detail: "push main + dev"
|
|
47
|
+
};
|
|
48
|
+
const defaultBranch = state.defaultBranch === "dev" ? {
|
|
49
|
+
action: "skip",
|
|
50
|
+
detail: "dev is already the default branch"
|
|
51
|
+
} : {
|
|
52
|
+
action: "update",
|
|
53
|
+
detail: "make dev the default branch"
|
|
54
|
+
};
|
|
55
|
+
const mainProtection = state.mainProtected ? {
|
|
56
|
+
action: "skip",
|
|
57
|
+
detail: "main is already protected"
|
|
58
|
+
} : {
|
|
59
|
+
action: "blocked",
|
|
60
|
+
detail: "requires GitHub Pro/Team on a private repo — set it manually in Settings → Branches (PR + review + the \"ci\" check required) if this stays blocked"
|
|
61
|
+
};
|
|
62
|
+
const prodEnvironment = state.environments.includes("production") ? {
|
|
63
|
+
action: "skip",
|
|
64
|
+
detail: "production environment already exists"
|
|
65
|
+
} : {
|
|
66
|
+
action: "blocked",
|
|
67
|
+
detail: "requires GitHub Pro/Team on a private repo — create a \"production\" environment gated on your admin collaborators manually if this stays blocked"
|
|
68
|
+
};
|
|
69
|
+
const devEnvironment = state.environments.includes("dev") ? {
|
|
70
|
+
action: "skip",
|
|
71
|
+
detail: "dev environment already exists"
|
|
72
|
+
} : {
|
|
73
|
+
action: "create",
|
|
74
|
+
detail: "create \"dev\" environment with no required reviewers (stays open)"
|
|
75
|
+
};
|
|
76
|
+
const d1 = setup.platform !== "cloudflare" || !setup.devResources ? {
|
|
77
|
+
action: "skip",
|
|
78
|
+
detail: "not a cloudflare project, or dev resources not requested"
|
|
79
|
+
} : state.d1Present ? {
|
|
80
|
+
action: "skip",
|
|
81
|
+
detail: `dev D1 already exists (${state.d1Id ?? "id unknown"})`
|
|
82
|
+
} : {
|
|
83
|
+
action: "create",
|
|
84
|
+
detail: `create D1 database "${setup.projectName}-dev"`
|
|
85
|
+
};
|
|
86
|
+
const r2 = setup.platform !== "cloudflare" || !setup.devResources ? {
|
|
87
|
+
action: "skip",
|
|
88
|
+
detail: "not a cloudflare project, or dev resources not requested"
|
|
89
|
+
} : state.r2Present ? {
|
|
90
|
+
action: "skip",
|
|
91
|
+
detail: `dev R2 bucket already exists`
|
|
92
|
+
} : {
|
|
93
|
+
action: "create",
|
|
94
|
+
detail: `create R2 bucket "${setup.projectName}-media-dev"`
|
|
95
|
+
};
|
|
96
|
+
const secrets = {};
|
|
97
|
+
for (const name of SECRETS_FOR(setup)) {
|
|
98
|
+
if (!state.ghAuthed || !state.originRemote) {
|
|
99
|
+
secrets[name] = {
|
|
100
|
+
action: "blocked",
|
|
101
|
+
detail: "no GitHub repo wired up yet — run the repo step first"
|
|
102
|
+
};
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (state.secretsPresent.includes(name)) {
|
|
106
|
+
secrets[name] = {
|
|
107
|
+
action: "skip",
|
|
108
|
+
detail: "already set"
|
|
109
|
+
};
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (name === "CLOUDFLARE_API_TOKEN") {
|
|
113
|
+
secrets[name] = {
|
|
114
|
+
action: "blocked",
|
|
115
|
+
detail: "tokens cannot be created via any API — create one at https://dash.cloudflare.com/profile/api-tokens (Workers + D1 + R2 edit), then `gh secret set CLOUDFLARE_API_TOKEN`"
|
|
116
|
+
};
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
secrets[name] = (name === "DEV_D1_ID" ? state.d1Id : name === "CLOUDFLARE_ACCOUNT_ID" ? state.accountId : void 0) ? {
|
|
120
|
+
action: "create",
|
|
121
|
+
detail: `set from provisioned/observed value`
|
|
122
|
+
} : {
|
|
123
|
+
action: "blocked",
|
|
124
|
+
detail: "value not yet known — provision the resource first"
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!state.wranglerAuthed && setup.platform === "cloudflare" && setup.devResources) notes.push("wrangler is not logged in — run `npx wrangler login` first");
|
|
128
|
+
if (!state.ghAuthed) notes.push("gh is not logged in — run `gh auth login` first");
|
|
129
|
+
return {
|
|
130
|
+
repo,
|
|
131
|
+
branches,
|
|
132
|
+
defaultBranch,
|
|
133
|
+
mainProtection,
|
|
134
|
+
prodEnvironment,
|
|
135
|
+
devEnvironment,
|
|
136
|
+
d1,
|
|
137
|
+
r2,
|
|
138
|
+
secrets,
|
|
139
|
+
notes
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const allSteps = (p) => [
|
|
143
|
+
p.repo,
|
|
144
|
+
p.branches,
|
|
145
|
+
p.defaultBranch,
|
|
146
|
+
p.mainProtection,
|
|
147
|
+
p.prodEnvironment,
|
|
148
|
+
p.devEnvironment,
|
|
149
|
+
p.d1,
|
|
150
|
+
p.r2,
|
|
151
|
+
...Object.values(p.secrets)
|
|
152
|
+
];
|
|
153
|
+
/** Any step that isn't a skip — i.e. the live state has drifted from desired. */
|
|
154
|
+
function hasDrift(p) {
|
|
155
|
+
return allSteps(p).some((s) => s.action !== "skip");
|
|
156
|
+
}
|
|
157
|
+
/** Every blocked step, with a name — what `--check` reports and the CLI surfaces. */
|
|
158
|
+
function blockedSteps(p) {
|
|
159
|
+
return [
|
|
160
|
+
["repo", p.repo],
|
|
161
|
+
["branches", p.branches],
|
|
162
|
+
["defaultBranch", p.defaultBranch],
|
|
163
|
+
["mainProtection", p.mainProtection],
|
|
164
|
+
["prodEnvironment", p.prodEnvironment],
|
|
165
|
+
["devEnvironment", p.devEnvironment],
|
|
166
|
+
["d1", p.d1],
|
|
167
|
+
["r2", p.r2],
|
|
168
|
+
...Object.entries(p.secrets).map(([name, s]) => [`secret:${name}`, s])
|
|
169
|
+
].filter(([, s]) => s.action === "blocked").map(([name, s]) => ({
|
|
170
|
+
name,
|
|
171
|
+
detail: s.detail
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
/** Render a plan as the block `--dry` and `--check` print. */
|
|
175
|
+
function formatPlan(p) {
|
|
176
|
+
const glyph = (s) => s.action === "skip" ? "·" : s.action === "blocked" ? "?" : s.action === "update" ? "~" : "+";
|
|
177
|
+
const lines = [];
|
|
178
|
+
lines.push(`${glyph(p.repo)} repo ${p.repo.detail}`);
|
|
179
|
+
lines.push(`${glyph(p.branches)} branches ${p.branches.detail}`);
|
|
180
|
+
lines.push(`${glyph(p.defaultBranch)} default branch ${p.defaultBranch.detail}`);
|
|
181
|
+
lines.push(`${glyph(p.mainProtection)} main protection ${p.mainProtection.detail}`);
|
|
182
|
+
lines.push(`${glyph(p.prodEnvironment)} production env ${p.prodEnvironment.detail}`);
|
|
183
|
+
lines.push(`${glyph(p.devEnvironment)} dev env ${p.devEnvironment.detail}`);
|
|
184
|
+
lines.push(`${glyph(p.d1)} dev D1 ${p.d1.detail}`);
|
|
185
|
+
lines.push(`${glyph(p.r2)} dev R2 ${p.r2.detail}`);
|
|
186
|
+
for (const [name, s] of Object.entries(p.secrets)) lines.push(`${glyph(s)} secret ${name}${" ".repeat(Math.max(1, 11 - name.length))}${s.detail}`);
|
|
187
|
+
for (const n of p.notes) lines.push(`! ${n}`);
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
|
190
|
+
/** Render the end-of-run summary: one row per step, plus blocked reminders. */
|
|
191
|
+
function formatSummary(results) {
|
|
192
|
+
return table([
|
|
193
|
+
"STEP",
|
|
194
|
+
"STATUS",
|
|
195
|
+
"DETAIL"
|
|
196
|
+
], results.map((r) => [
|
|
197
|
+
r.step,
|
|
198
|
+
r.status,
|
|
199
|
+
r.detail
|
|
200
|
+
]), results.filter((r) => r.status === "blocked").map((r) => ` ! ${r.step}: ${r.detail}`));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Plan the domain-consistency check on its own — it has no executor, only an
|
|
204
|
+
* observation (`wrangler.jsonc` + the config), and it is never applied by this
|
|
205
|
+
* command. See `SETUP.html`'s "Domain strategy" stage: the actual cutover is a
|
|
206
|
+
* human, deliberate action.
|
|
207
|
+
*/
|
|
208
|
+
function planDomainConsistency(state) {
|
|
209
|
+
if (Boolean(state.customDomain)) {
|
|
210
|
+
if (!state.canonical || state.canonical !== `https://${state.customDomain}`) return {
|
|
211
|
+
action: "blocked",
|
|
212
|
+
detail: `wrangler.jsonc names a custom domain (${state.customDomain}) but site.domains.canonical doesn't match — a visitor sees the new site while <link rel=canonical> still points at the old one`
|
|
213
|
+
};
|
|
214
|
+
if (state.robots?.includes("noindex")) return {
|
|
215
|
+
action: "blocked",
|
|
216
|
+
detail: "custom domain is live but robots is still \"noindex,nofollow\" — flip it back after cutover"
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
action: "skip",
|
|
220
|
+
detail: "custom domain configured and consistent"
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!state.robots?.includes("noindex")) return {
|
|
224
|
+
action: "blocked",
|
|
225
|
+
detail: "still on *.workers.dev but robots does not say \"noindex,nofollow\" — a workers.dev URL is indexable, and this is what stops search engines treating it as a duplicate of the real site"
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
action: "skip",
|
|
229
|
+
detail: "workers.dev phase, correctly noindexed"
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/hosting/wrangler.ts
|
|
235
|
+
/**
|
|
236
|
+
* Cloudflare resources — dev D1 + R2, plus the account id. Executes a plan and
|
|
237
|
+
* decides nothing; every observation and mutation is a structured result, never a
|
|
238
|
+
* throw, so the CLI decides what a failure means.
|
|
239
|
+
*
|
|
240
|
+
* Shells out to `wrangler` and reads `gh`'s account-id output — there is no REST
|
|
241
|
+
* alternative that doesn't also need the same credential wrangler already holds
|
|
242
|
+
* from `wrangler login`. Same class of executor as `@getvitops/utils/media`
|
|
243
|
+
* shelling to `ffmpeg`: an external tool, not an npm dependency, with `execImpl`
|
|
244
|
+
* injected so the parsing is testable without a real Cloudflare account.
|
|
245
|
+
*/
|
|
246
|
+
const exec = promisify(execFile);
|
|
247
|
+
/** The real executor — `execFile` with an argv array, never a shell string. */
|
|
248
|
+
const realExec = async (cmd, args) => {
|
|
249
|
+
try {
|
|
250
|
+
const { stdout } = await exec(cmd, args, { maxBuffer: 16 * 1024 * 1024 });
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
stdout
|
|
254
|
+
};
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const e = err;
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
stdout: e.stdout ?? "",
|
|
260
|
+
message: e.stderr || e.message || "command failed"
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
/** `wrangler whoami` — auth check + the 32-hex account id, from one call. */
|
|
265
|
+
async function wranglerWhoami(execImpl = realExec) {
|
|
266
|
+
const res = await execImpl("npx", ["wrangler", "whoami"]);
|
|
267
|
+
if (!res.ok) return { authed: false };
|
|
268
|
+
const accountId = /\b([0-9a-f]{32})\b/.exec(res.stdout)?.[1];
|
|
269
|
+
return {
|
|
270
|
+
authed: true,
|
|
271
|
+
...accountId ? { accountId } : {}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Find an existing dev D1 database's id by name.
|
|
276
|
+
*
|
|
277
|
+
* `d1 create`'s own stdout carries the id, but its format has drifted across
|
|
278
|
+
* wrangler versions before — a re-list is the one thing guaranteed to still hold
|
|
279
|
+
* the answer, so both the "does it exist" check and the post-create fallback go
|
|
280
|
+
* through this.
|
|
281
|
+
*/
|
|
282
|
+
async function findD1(name, execImpl = realExec) {
|
|
283
|
+
const res = await execImpl("npx", [
|
|
284
|
+
"wrangler",
|
|
285
|
+
"d1",
|
|
286
|
+
"list",
|
|
287
|
+
"--json"
|
|
288
|
+
]);
|
|
289
|
+
if (!res.ok) return void 0;
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(res.stdout).find((d) => d.name === name)?.uuid;
|
|
292
|
+
} catch {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Create a dev D1 database, then resolve its id via `findD1` (see its docblock). */
|
|
297
|
+
async function createD1(name, execImpl = realExec) {
|
|
298
|
+
const res = await execImpl("npx", [
|
|
299
|
+
"wrangler",
|
|
300
|
+
"d1",
|
|
301
|
+
"create",
|
|
302
|
+
name
|
|
303
|
+
]);
|
|
304
|
+
if (!res.ok) return {
|
|
305
|
+
ok: false,
|
|
306
|
+
message: res.message
|
|
307
|
+
};
|
|
308
|
+
const id = /"database_id":\s*"([0-9a-f-]{36})"/.exec(res.stdout)?.[1] ?? await findD1(name, execImpl);
|
|
309
|
+
if (!id) return {
|
|
310
|
+
ok: false,
|
|
311
|
+
message: "created, but could not determine the database id"
|
|
312
|
+
};
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
id
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async function findR2Bucket(name, execImpl = realExec) {
|
|
319
|
+
const res = await execImpl("npx", [
|
|
320
|
+
"wrangler",
|
|
321
|
+
"r2",
|
|
322
|
+
"bucket",
|
|
323
|
+
"list"
|
|
324
|
+
]);
|
|
325
|
+
if (!res.ok) return false;
|
|
326
|
+
return new RegExp(`name:\\s+${name}\\s*$`, "m").test(res.stdout);
|
|
327
|
+
}
|
|
328
|
+
async function createR2Bucket(name, execImpl = realExec) {
|
|
329
|
+
const res = await execImpl("npx", [
|
|
330
|
+
"wrangler",
|
|
331
|
+
"r2",
|
|
332
|
+
"bucket",
|
|
333
|
+
"create",
|
|
334
|
+
name
|
|
335
|
+
]);
|
|
336
|
+
return res.ok ? { ok: true } : {
|
|
337
|
+
ok: false,
|
|
338
|
+
message: res.message
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/hosting/github.ts
|
|
344
|
+
async function ghAuthed(execImpl = realExec) {
|
|
345
|
+
return (await execImpl("gh", ["auth", "status"])).ok;
|
|
346
|
+
}
|
|
347
|
+
async function originRemote(execImpl = realExec) {
|
|
348
|
+
const res = await execImpl("git", [
|
|
349
|
+
"remote",
|
|
350
|
+
"get-url",
|
|
351
|
+
"origin"
|
|
352
|
+
]);
|
|
353
|
+
return res.ok ? res.stdout.trim() : void 0;
|
|
354
|
+
}
|
|
355
|
+
async function repoNwo(execImpl = realExec) {
|
|
356
|
+
const res = await execImpl("gh", [
|
|
357
|
+
"repo",
|
|
358
|
+
"view",
|
|
359
|
+
"--json",
|
|
360
|
+
"nameWithOwner"
|
|
361
|
+
]);
|
|
362
|
+
if (!res.ok) return void 0;
|
|
363
|
+
try {
|
|
364
|
+
return JSON.parse(res.stdout).nameWithOwner;
|
|
365
|
+
} catch {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function branches(execImpl = realExec) {
|
|
370
|
+
const res = await execImpl("git", [
|
|
371
|
+
"branch",
|
|
372
|
+
"--list",
|
|
373
|
+
"--format=%(refname:short)"
|
|
374
|
+
]);
|
|
375
|
+
if (!res.ok) return [];
|
|
376
|
+
return res.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
377
|
+
}
|
|
378
|
+
async function defaultBranch(nwo, execImpl = realExec) {
|
|
379
|
+
const res = await execImpl("gh", [
|
|
380
|
+
"repo",
|
|
381
|
+
"view",
|
|
382
|
+
nwo,
|
|
383
|
+
"--json",
|
|
384
|
+
"defaultBranchRef"
|
|
385
|
+
]);
|
|
386
|
+
if (!res.ok) return void 0;
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(res.stdout).defaultBranchRef?.name;
|
|
389
|
+
} catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async function mainProtected(nwo, execImpl = realExec) {
|
|
394
|
+
return (await execImpl("gh", ["api", `repos/${nwo}/branches/main/protection`])).ok;
|
|
395
|
+
}
|
|
396
|
+
async function environments(nwo, execImpl = realExec) {
|
|
397
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/environments`]);
|
|
398
|
+
if (!res.ok) return [];
|
|
399
|
+
try {
|
|
400
|
+
return (JSON.parse(res.stdout).environments ?? []).map((e) => e.name);
|
|
401
|
+
} catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function secretsPresent(nwo, execImpl = realExec) {
|
|
406
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/actions/secrets`]);
|
|
407
|
+
if (!res.ok) return [];
|
|
408
|
+
try {
|
|
409
|
+
return (JSON.parse(res.stdout).secrets ?? []).map((s) => s.name);
|
|
410
|
+
} catch {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function createRepo(name, visibility, execImpl = realExec) {
|
|
415
|
+
const res = await execImpl("gh", [
|
|
416
|
+
"repo",
|
|
417
|
+
"create",
|
|
418
|
+
name,
|
|
419
|
+
`--${visibility}`,
|
|
420
|
+
"--source=.",
|
|
421
|
+
"--remote=origin"
|
|
422
|
+
]);
|
|
423
|
+
return res.ok ? { ok: true } : {
|
|
424
|
+
ok: false,
|
|
425
|
+
message: res.message
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function pushBranches(execImpl = realExec) {
|
|
429
|
+
const res = await execImpl("git", [
|
|
430
|
+
"push",
|
|
431
|
+
"-u",
|
|
432
|
+
"origin",
|
|
433
|
+
"main",
|
|
434
|
+
"dev"
|
|
435
|
+
]);
|
|
436
|
+
return res.ok ? { ok: true } : {
|
|
437
|
+
ok: false,
|
|
438
|
+
message: res.message
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
async function setDefaultBranch(nwo, branch, execImpl = realExec) {
|
|
442
|
+
const res = await execImpl("gh", [
|
|
443
|
+
"repo",
|
|
444
|
+
"edit",
|
|
445
|
+
nwo,
|
|
446
|
+
`--default-branch=${branch}`
|
|
447
|
+
]);
|
|
448
|
+
return res.ok ? { ok: true } : {
|
|
449
|
+
ok: false,
|
|
450
|
+
message: res.message
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* `PUT .../branches/main/protection`. A 403 on Pro/Team-gated repos is expected.
|
|
455
|
+
*
|
|
456
|
+
* Every field goes through `gh api`'s `-f`/`-F` form flags rather than a JSON body
|
|
457
|
+
* on stdin — `gh api` supports nested keys (`a[b]=c`) and repeated array keys
|
|
458
|
+
* (`a[]=x`) this way, so no request needs a second I/O channel `ExecImpl` would
|
|
459
|
+
* have to carry just for this one call.
|
|
460
|
+
*/
|
|
461
|
+
async function protectMain(nwo, execImpl = realExec) {
|
|
462
|
+
const res = await execImpl("gh", [
|
|
463
|
+
"api",
|
|
464
|
+
"--method",
|
|
465
|
+
"PUT",
|
|
466
|
+
`repos/${nwo}/branches/main/protection`,
|
|
467
|
+
"-F",
|
|
468
|
+
"required_status_checks[strict]=true",
|
|
469
|
+
"-f",
|
|
470
|
+
"required_status_checks[contexts][]=ci",
|
|
471
|
+
"-F",
|
|
472
|
+
"enforce_admins=false",
|
|
473
|
+
"-F",
|
|
474
|
+
"required_pull_request_reviews[required_approving_review_count]=1",
|
|
475
|
+
"-F",
|
|
476
|
+
"restrictions=null",
|
|
477
|
+
"-F",
|
|
478
|
+
"allow_force_pushes=false",
|
|
479
|
+
"-F",
|
|
480
|
+
"allow_deletions=false"
|
|
481
|
+
]);
|
|
482
|
+
return res.ok ? { ok: true } : {
|
|
483
|
+
ok: false,
|
|
484
|
+
message: res.message
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/** The repo's admin collaborators — numeric user ids, which is what the
|
|
488
|
+
* environment-reviewers API wants (not logins). */
|
|
489
|
+
async function adminCollaboratorIds(nwo, execImpl = realExec) {
|
|
490
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/collaborators?permission=admin`]);
|
|
491
|
+
if (!res.ok) return [];
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(res.stdout).map((c) => c.id);
|
|
494
|
+
} catch {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** `PUT .../environments/production`, reviewers = current admin collaborators. */
|
|
499
|
+
async function createProdEnvironment(nwo, reviewerUserIds, execImpl = realExec) {
|
|
500
|
+
const res = await execImpl("gh", [
|
|
501
|
+
"api",
|
|
502
|
+
"--method",
|
|
503
|
+
"PUT",
|
|
504
|
+
`repos/${nwo}/environments/production`,
|
|
505
|
+
"-f",
|
|
506
|
+
`deployment_branch_policy[protected_branches]=true`,
|
|
507
|
+
"-f",
|
|
508
|
+
`deployment_branch_policy[custom_branch_policies]=false`,
|
|
509
|
+
...reviewerUserIds.flatMap((id) => [
|
|
510
|
+
"-F",
|
|
511
|
+
`reviewers[][id]=${id}`,
|
|
512
|
+
"-f",
|
|
513
|
+
"reviewers[][type]=User"
|
|
514
|
+
])
|
|
515
|
+
]);
|
|
516
|
+
return res.ok ? { ok: true } : {
|
|
517
|
+
ok: false,
|
|
518
|
+
message: res.message
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/** `PUT .../environments/dev` with no reviewers — deliberately left open. */
|
|
522
|
+
async function createDevEnvironment(nwo, execImpl = realExec) {
|
|
523
|
+
const res = await execImpl("gh", [
|
|
524
|
+
"api",
|
|
525
|
+
"--method",
|
|
526
|
+
"PUT",
|
|
527
|
+
`repos/${nwo}/environments/dev`
|
|
528
|
+
]);
|
|
529
|
+
return res.ok ? { ok: true } : {
|
|
530
|
+
ok: false,
|
|
531
|
+
message: res.message
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function setSecret(name, value, execImpl = realExec) {
|
|
535
|
+
const res = await execImpl("gh", [
|
|
536
|
+
"secret",
|
|
537
|
+
"set",
|
|
538
|
+
name,
|
|
539
|
+
"--body",
|
|
540
|
+
value
|
|
541
|
+
]);
|
|
542
|
+
return res.ok ? { ok: true } : {
|
|
543
|
+
ok: false,
|
|
544
|
+
message: res.message
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
//#endregion
|
|
549
|
+
export { adminCollaboratorIds, blockedSteps, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan, formatSummary, ghAuthed, hasDrift, mainProtected, originRemote, plan, planDomainConsistency, protectMain, pushBranches, realExec, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami };
|
|
550
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/hosting/plan.ts","../../src/hosting/wrangler.ts","../../src/hosting/github.ts"],"sourcesContent":["/**\n * Every decision `vitops setup` makes, as one pure function.\n *\n * Same split as `onboarding/plan.ts` and `ads/plan.ts`: this module decides, from\n * observed live state, what's left to do; `wrangler.ts`/`github.ts` execute a plan\n * and decide nothing. That is what makes `--dry` a complete account of a run and\n * `--check` an honest drift report with no network beyond the observation step.\n *\n * Idempotency lives here: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-provisioned project is all skips by construction.\n *\n * `blocked` (ads' addition to onboarding's three-state `StepAction`) covers the one\n * thing this command genuinely cannot do: mint a Cloudflare API token. That is\n * config nothing here can supply — surfaced as blocked, carrying the manual path,\n * never as a failure.\n *\n * Never destructive: no step here deletes a database, a bucket, a branch, a\n * protection rule, or a DNS record — matching `onboarding/cloudflare.ts`'s DNS\n * verbs, which have no update or delete either.\n */\nimport { table } from '../plan-format.ts';\nimport type { DomainState, HostingSetup, HostingState } from './types.ts';\n\nexport type StepAction = 'create' | 'update' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n detail: string;\n}\n\nexport interface HostingPlan {\n repo: StepPlan;\n branches: StepPlan;\n defaultBranch: StepPlan;\n mainProtection: StepPlan;\n prodEnvironment: StepPlan;\n devEnvironment: StepPlan;\n d1: StepPlan;\n r2: StepPlan;\n /** One step per repo secret this command sets. */\n secrets: Record<string, StepPlan>;\n notes: string[];\n}\n\nconst SECRETS_FOR = (setup: HostingSetup): string[] =>\n setup.platform === 'cloudflare'\n ? ['DEV_D1_ID', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN']\n : [];\n\n/** Plan the whole hosting sequence from setup + observed state. */\nexport function plan(setup: HostingSetup, state: HostingState): HostingPlan {\n const notes: string[] = [];\n\n const repo: StepPlan =\n state.originRemote && state.repoNwo\n ? { action: 'skip', detail: `repo already exists (${state.repoNwo})` }\n : {\n action: 'create',\n detail: `create GitHub repo \"${setup.projectName}\" (${setup.repoVisibility})`,\n };\n\n const branches: StepPlan =\n state.branches.includes('main') && state.branches.includes('dev')\n ? { action: 'skip', detail: 'main + dev already exist' }\n : { action: 'create', detail: 'push main + dev' };\n\n const defaultBranch: StepPlan =\n state.defaultBranch === 'dev'\n ? { action: 'skip', detail: 'dev is already the default branch' }\n : { action: 'update', detail: 'make dev the default branch' };\n\n const mainProtection: StepPlan = state.mainProtected\n ? { action: 'skip', detail: 'main is already protected' }\n : {\n action: 'blocked',\n detail:\n 'requires GitHub Pro/Team on a private repo — set it manually in Settings → Branches ' +\n '(PR + review + the \"ci\" check required) if this stays blocked',\n };\n\n const prodEnvironment: StepPlan = state.environments.includes('production')\n ? { action: 'skip', detail: 'production environment already exists' }\n : {\n action: 'blocked',\n detail:\n 'requires GitHub Pro/Team on a private repo — create a \"production\" environment ' +\n 'gated on your admin collaborators manually if this stays blocked',\n };\n\n const devEnvironment: StepPlan = state.environments.includes('dev')\n ? { action: 'skip', detail: 'dev environment already exists' }\n : {\n action: 'create',\n detail: 'create \"dev\" environment with no required reviewers (stays open)',\n };\n\n const d1: StepPlan =\n setup.platform !== 'cloudflare' || !setup.devResources\n ? { action: 'skip', detail: 'not a cloudflare project, or dev resources not requested' }\n : state.d1Present\n ? { action: 'skip', detail: `dev D1 already exists (${state.d1Id ?? 'id unknown'})` }\n : { action: 'create', detail: `create D1 database \"${setup.projectName}-dev\"` };\n\n const r2: StepPlan =\n setup.platform !== 'cloudflare' || !setup.devResources\n ? { action: 'skip', detail: 'not a cloudflare project, or dev resources not requested' }\n : state.r2Present\n ? { action: 'skip', detail: `dev R2 bucket already exists` }\n : { action: 'create', detail: `create R2 bucket \"${setup.projectName}-media-dev\"` };\n\n const secrets: Record<string, StepPlan> = {};\n for (const name of SECRETS_FOR(setup)) {\n if (!state.ghAuthed || !state.originRemote) {\n secrets[name] = {\n action: 'blocked',\n detail: 'no GitHub repo wired up yet — run the repo step first',\n };\n continue;\n }\n if (state.secretsPresent.includes(name)) {\n secrets[name] = { action: 'skip', detail: 'already set' };\n continue;\n }\n if (name === 'CLOUDFLARE_API_TOKEN') {\n secrets[name] = {\n action: 'blocked',\n detail:\n 'tokens cannot be created via any API — create one at ' +\n 'https://dash.cloudflare.com/profile/api-tokens (Workers + D1 + R2 edit), then ' +\n '`gh secret set CLOUDFLARE_API_TOKEN`',\n };\n continue;\n }\n const value =\n name === 'DEV_D1_ID'\n ? state.d1Id\n : name === 'CLOUDFLARE_ACCOUNT_ID'\n ? state.accountId\n : undefined;\n secrets[name] = value\n ? { action: 'create', detail: `set from provisioned/observed value` }\n : { action: 'blocked', detail: 'value not yet known — provision the resource first' };\n }\n\n if (!state.wranglerAuthed && setup.platform === 'cloudflare' && setup.devResources)\n notes.push('wrangler is not logged in — run `npx wrangler login` first');\n if (!state.ghAuthed) notes.push('gh is not logged in — run `gh auth login` first');\n\n return {\n repo,\n branches,\n defaultBranch,\n mainProtection,\n prodEnvironment,\n devEnvironment,\n d1,\n r2,\n secrets,\n notes,\n };\n}\n\nconst allSteps = (p: HostingPlan): StepPlan[] => [\n p.repo,\n p.branches,\n p.defaultBranch,\n p.mainProtection,\n p.prodEnvironment,\n p.devEnvironment,\n p.d1,\n p.r2,\n ...Object.values(p.secrets),\n];\n\n/** Any step that isn't a skip — i.e. the live state has drifted from desired. */\nexport function hasDrift(p: HostingPlan): boolean {\n return allSteps(p).some((s) => s.action !== 'skip');\n}\n\n/** Every blocked step, with a name — what `--check` reports and the CLI surfaces. */\nexport function blockedSteps(p: HostingPlan): { name: string; detail: string }[] {\n const named: [string, StepPlan][] = [\n ['repo', p.repo],\n ['branches', p.branches],\n ['defaultBranch', p.defaultBranch],\n ['mainProtection', p.mainProtection],\n ['prodEnvironment', p.prodEnvironment],\n ['devEnvironment', p.devEnvironment],\n ['d1', p.d1],\n ['r2', p.r2],\n ...Object.entries(p.secrets).map(([name, s]) => [`secret:${name}`, s] as [string, StepPlan]),\n ];\n return named\n .filter(([, s]) => s.action === 'blocked')\n .map(([name, s]) => ({ name, detail: s.detail }));\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: HostingPlan): string {\n const glyph = (s: StepPlan) =>\n s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : s.action === 'update' ? '~' : '+';\n const lines: string[] = [];\n lines.push(`${glyph(p.repo)} repo ${p.repo.detail}`);\n lines.push(`${glyph(p.branches)} branches ${p.branches.detail}`);\n lines.push(`${glyph(p.defaultBranch)} default branch ${p.defaultBranch.detail}`);\n lines.push(`${glyph(p.mainProtection)} main protection ${p.mainProtection.detail}`);\n lines.push(`${glyph(p.prodEnvironment)} production env ${p.prodEnvironment.detail}`);\n lines.push(`${glyph(p.devEnvironment)} dev env ${p.devEnvironment.detail}`);\n lines.push(`${glyph(p.d1)} dev D1 ${p.d1.detail}`);\n lines.push(`${glyph(p.r2)} dev R2 ${p.r2.detail}`);\n for (const [name, s] of Object.entries(p.secrets))\n lines.push(`${glyph(s)} secret ${name}${' '.repeat(Math.max(1, 11 - name.length))}${s.detail}`);\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one run, for the summary table. */\nexport interface HostingResult {\n step: string;\n status: 'created' | 'updated' | 'present' | 'blocked' | 'failed';\n detail: string;\n}\n\n/** Render the end-of-run summary: one row per step, plus blocked reminders. */\nexport function formatSummary(results: HostingResult[]): string {\n const headers = ['STEP', 'STATUS', 'DETAIL'];\n const rows = results.map((r) => [r.step, r.status, r.detail]);\n const reminders = results\n .filter((r) => r.status === 'blocked')\n .map((r) => ` ! ${r.step}: ${r.detail}`);\n return table(headers, rows, reminders);\n}\n\n/**\n * Plan the domain-consistency check on its own — it has no executor, only an\n * observation (`wrangler.jsonc` + the config), and it is never applied by this\n * command. See `SETUP.html`'s \"Domain strategy\" stage: the actual cutover is a\n * human, deliberate action.\n */\nexport function planDomainConsistency(state: DomainState): StepPlan {\n const cutover = Boolean(state.customDomain);\n if (cutover) {\n if (!state.canonical || state.canonical !== `https://${state.customDomain}`)\n return {\n action: 'blocked',\n detail: `wrangler.jsonc names a custom domain (${state.customDomain}) but site.domains.canonical doesn't match — a visitor sees the new site while <link rel=canonical> still points at the old one`,\n };\n if (state.robots?.includes('noindex'))\n return {\n action: 'blocked',\n detail:\n 'custom domain is live but robots is still \"noindex,nofollow\" — flip it back after cutover',\n };\n return { action: 'skip', detail: 'custom domain configured and consistent' };\n }\n if (!state.robots?.includes('noindex'))\n return {\n action: 'blocked',\n detail:\n 'still on *.workers.dev but robots does not say \"noindex,nofollow\" — a workers.dev URL is indexable, and this is what stops search engines treating it as a duplicate of the real site',\n };\n return { action: 'skip', detail: 'workers.dev phase, correctly noindexed' };\n}\n","/**\n * Cloudflare resources — dev D1 + R2, plus the account id. Executes a plan and\n * decides nothing; every observation and mutation is a structured result, never a\n * throw, so the CLI decides what a failure means.\n *\n * Shells out to `wrangler` and reads `gh`'s account-id output — there is no REST\n * alternative that doesn't also need the same credential wrangler already holds\n * from `wrangler login`. Same class of executor as `@getvitops/utils/media`\n * shelling to `ffmpeg`: an external tool, not an npm dependency, with `execImpl`\n * injected so the parsing is testable without a real Cloudflare account.\n */\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst exec = promisify(execFile);\n\nexport interface ExecResult {\n ok: boolean;\n stdout: string;\n message?: string | undefined;\n}\n\nexport type ExecImpl = (cmd: string, args: string[]) => Promise<ExecResult>;\n\n/** The real executor — `execFile` with an argv array, never a shell string. */\nexport const realExec: ExecImpl = async (cmd, args) => {\n try {\n const { stdout } = await exec(cmd, args, { maxBuffer: 16 * 1024 * 1024 });\n return { ok: true, stdout };\n } catch (err) {\n const e = err as { stdout?: string; stderr?: string; message?: string };\n return {\n ok: false,\n stdout: e.stdout ?? '',\n message: e.stderr || e.message || 'command failed',\n };\n }\n};\n\n/** `wrangler whoami` — auth check + the 32-hex account id, from one call. */\nexport async function wranglerWhoami(\n execImpl: ExecImpl = realExec,\n): Promise<{ authed: boolean; accountId?: string }> {\n const res = await execImpl('npx', ['wrangler', 'whoami']);\n if (!res.ok) return { authed: false };\n const accountId = /\\b([0-9a-f]{32})\\b/.exec(res.stdout)?.[1];\n return { authed: true, ...(accountId ? { accountId } : {}) };\n}\n\n/**\n * Find an existing dev D1 database's id by name.\n *\n * `d1 create`'s own stdout carries the id, but its format has drifted across\n * wrangler versions before — a re-list is the one thing guaranteed to still hold\n * the answer, so both the \"does it exist\" check and the post-create fallback go\n * through this.\n */\nexport async function findD1(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<string | undefined> {\n const res = await execImpl('npx', ['wrangler', 'd1', 'list', '--json']);\n if (!res.ok) return undefined;\n try {\n const list = JSON.parse(res.stdout) as { name: string; uuid: string }[];\n return list.find((d) => d.name === name)?.uuid;\n } catch {\n return undefined;\n }\n}\n\n/** Create a dev D1 database, then resolve its id via `findD1` (see its docblock). */\nexport async function createD1(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; id?: string | undefined; message?: string | undefined }> {\n const res = await execImpl('npx', ['wrangler', 'd1', 'create', name]);\n if (!res.ok) return { ok: false, message: res.message };\n const inline = /\"database_id\":\\s*\"([0-9a-f-]{36})\"/.exec(res.stdout)?.[1];\n const id = inline ?? (await findD1(name, execImpl));\n if (!id) return { ok: false, message: 'created, but could not determine the database id' };\n return { ok: true, id };\n}\n\nexport async function findR2Bucket(name: string, execImpl: ExecImpl = realExec): Promise<boolean> {\n const res = await execImpl('npx', ['wrangler', 'r2', 'bucket', 'list']);\n if (!res.ok) return false;\n return new RegExp(`name:\\\\s+${name}\\\\s*$`, 'm').test(res.stdout);\n}\n\nexport async function createR2Bucket(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('npx', ['wrangler', 'r2', 'bucket', 'create', name]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n","/**\n * GitHub repo, branches, environments and secrets. Executes a plan and decides\n * nothing — see `wrangler.ts`'s docblock for why this shells out (`gh` carries the\n * auth; there is no token-free REST alternative) and for the `execImpl` seam.\n *\n * Absorbs the behaviour of the templates' old `scripts/init-github.mjs`:\n * `dev` is left with **no required reviewers**, deliberately — that is what keeps\n * it the unprotected integration branch. `main` protection and the `production`\n * environment's reviewer policy both 403 on a private repo without GitHub\n * Pro/Team; that is reported as `blocked`, not `failed` — see `plan.ts`.\n */\nimport type { ExecImpl } from './wrangler.ts';\nimport { realExec } from './wrangler.ts';\n\nexport async function ghAuthed(execImpl: ExecImpl = realExec): Promise<boolean> {\n return (await execImpl('gh', ['auth', 'status'])).ok;\n}\n\nexport async function originRemote(execImpl: ExecImpl = realExec): Promise<string | undefined> {\n const res = await execImpl('git', ['remote', 'get-url', 'origin']);\n return res.ok ? res.stdout.trim() : undefined;\n}\n\nexport async function repoNwo(execImpl: ExecImpl = realExec): Promise<string | undefined> {\n const res = await execImpl('gh', ['repo', 'view', '--json', 'nameWithOwner']);\n if (!res.ok) return undefined;\n try {\n return (JSON.parse(res.stdout) as { nameWithOwner: string }).nameWithOwner;\n } catch {\n return undefined;\n }\n}\n\nexport async function branches(execImpl: ExecImpl = realExec): Promise<string[]> {\n const res = await execImpl('git', ['branch', '--list', '--format=%(refname:short)']);\n if (!res.ok) return [];\n return res.stdout\n .split('\\n')\n .map((l) => l.trim())\n .filter(Boolean);\n}\n\nexport async function defaultBranch(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<string | undefined> {\n const res = await execImpl('gh', ['repo', 'view', nwo, '--json', 'defaultBranchRef']);\n if (!res.ok) return undefined;\n try {\n return (JSON.parse(res.stdout) as { defaultBranchRef?: { name: string } }).defaultBranchRef\n ?.name;\n } catch {\n return undefined;\n }\n}\n\nexport async function mainProtected(nwo: string, execImpl: ExecImpl = realExec): Promise<boolean> {\n return (await execImpl('gh', ['api', `repos/${nwo}/branches/main/protection`])).ok;\n}\n\nexport async function environments(nwo: string, execImpl: ExecImpl = realExec): Promise<string[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/environments`]);\n if (!res.ok) return [];\n try {\n return (\n (JSON.parse(res.stdout) as { environments?: { name: string }[] }).environments ?? []\n ).map((e) => e.name);\n } catch {\n return [];\n }\n}\n\nexport async function secretsPresent(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<string[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/actions/secrets`]);\n if (!res.ok) return [];\n try {\n return ((JSON.parse(res.stdout) as { secrets?: { name: string }[] }).secrets ?? []).map(\n (s) => s.name,\n );\n } catch {\n return [];\n }\n}\n\n// ── Mutations ─────────────────────────────────────────────────────────────────\n\nexport async function createRepo(\n name: string,\n visibility: 'public' | 'private',\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'repo',\n 'create',\n name,\n `--${visibility}`,\n '--source=.',\n '--remote=origin',\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function pushBranches(\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('git', ['push', '-u', 'origin', 'main', 'dev']);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function setDefaultBranch(\n nwo: string,\n branch: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['repo', 'edit', nwo, `--default-branch=${branch}`]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/**\n * `PUT .../branches/main/protection`. A 403 on Pro/Team-gated repos is expected.\n *\n * Every field goes through `gh api`'s `-f`/`-F` form flags rather than a JSON body\n * on stdin — `gh api` supports nested keys (`a[b]=c`) and repeated array keys\n * (`a[]=x`) this way, so no request needs a second I/O channel `ExecImpl` would\n * have to carry just for this one call.\n */\nexport async function protectMain(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'api',\n '--method',\n 'PUT',\n `repos/${nwo}/branches/main/protection`,\n '-F',\n 'required_status_checks[strict]=true',\n '-f',\n 'required_status_checks[contexts][]=ci',\n '-F',\n 'enforce_admins=false',\n '-F',\n 'required_pull_request_reviews[required_approving_review_count]=1',\n '-F',\n 'restrictions=null',\n '-F',\n 'allow_force_pushes=false',\n '-F',\n 'allow_deletions=false',\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/** The repo's admin collaborators — numeric user ids, which is what the\n * environment-reviewers API wants (not logins). */\nexport async function adminCollaboratorIds(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<number[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/collaborators?permission=admin`]);\n if (!res.ok) return [];\n try {\n return (JSON.parse(res.stdout) as { id: number }[]).map((c) => c.id);\n } catch {\n return [];\n }\n}\n\n/** `PUT .../environments/production`, reviewers = current admin collaborators. */\nexport async function createProdEnvironment(\n nwo: string,\n reviewerUserIds: number[],\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'api',\n '--method',\n 'PUT',\n `repos/${nwo}/environments/production`,\n '-f',\n `deployment_branch_policy[protected_branches]=true`,\n '-f',\n `deployment_branch_policy[custom_branch_policies]=false`,\n ...reviewerUserIds.flatMap((id) => [\n '-F',\n `reviewers[][id]=${id}`,\n '-f',\n 'reviewers[][type]=User',\n ]),\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/** `PUT .../environments/dev` with no reviewers — deliberately left open. */\nexport async function createDevEnvironment(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['api', '--method', 'PUT', `repos/${nwo}/environments/dev`]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function setSecret(\n name: string,\n value: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['secret', 'set', name, '--body', value]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { a as googleHeaders, i as googleAccessToken, n as encodeSite, r as errorDetail } from "../token-DkKC5m23.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { t as table } from "../plan-format-A18SvJ7C.mjs";
|
|
3
|
+
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
|
|
3
4
|
|
|
4
5
|
//#region src/onboarding/plan.ts
|
|
5
6
|
/** The `sc-domain:` property identifier for a bare domain. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n return table(headers, rows, reminders);\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport {\n encodeSite,\n errorDetail,\n googleAccessToken,\n googleHeaders,\n type GoogleAuthLike,\n type GoogleOAuth,\n} from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: googleHeaders(auth),\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n auth: GoogleAuthLike,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const message = await errorDetail(res, (status) =>\n status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : undefined,\n );\n return { ok: false, status: res.status, message };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/onboarding/plan.ts","../../src/onboarding/google.ts"],"sourcesContent":["/**\n * Every decision `vitops search setup` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. The executors\n * beside this one gather each domain's live state and apply what they are told;\n * this module decides, from that state, what is left to do. That split is what\n * makes `--dry` a complete account of a run and `--check` an honest drift report —\n * the same arrangement the indexing planner makes against `gsc.ts`/`indexnow.ts`.\n *\n * Idempotency lives here too: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-onboarded domain is all skips — a no-op, by\n * construction rather than by the executors checking twice.\n */\nimport type { DomainSetup, DomainState, OnboardingConfig } from './types.ts';\nimport { table } from '../plan-format.ts';\n\n/** A step's decision: do it, or skip it because it already holds. */\nexport type StepAction = 'create' | 'update' | 'skip';\n\nexport interface StepPlan {\n action: StepAction;\n /** Why it's a skip, or what the mutation will do. */\n detail: string;\n}\n\n/** The four onboarding steps for one domain, decided against its live state. */\nexport interface DomainPlan {\n domain: string;\n /** `sc-domain:` property identifier this domain maps to. */\n siteUrl: string;\n /** Ensure the apex TXT verification record. */\n txt: StepPlan;\n /** Verify ownership via the Site Verification API (DNS_TXT). */\n verify: StepPlan;\n /** Add the Search Console domain property. */\n property: StepPlan;\n /** Add delegated owners to the verified web resource. */\n owners: StepPlan;\n /** Owners not yet present that the run should add. */\n ownersToAdd: string[];\n /** Manual follow-ups (e.g. a Full-User group). */\n reminders: string[];\n}\n\nexport interface OnboardingPlan {\n domains: DomainPlan[];\n /** Operator-facing warnings that span the whole run. */\n notes: string[];\n}\n\n/** The `sc-domain:` property identifier for a bare domain. */\nexport const siteUrlFor = (domain: string): string => `sc-domain:${domain}`;\n\n/**\n * Backoff ladder for the verification retry, in milliseconds.\n *\n * DNS_TXT verification fails until the record has propagated, which is a matter of\n * seconds-to-minutes, not a real error — so the schedule is a *policy*, decided\n * here and asserted in the tests, while the actual sleeping is the CLI's job.\n * Capped at `maxAttempts`; a domain still unverified after the last attempt is\n * reported PENDING, never failed.\n */\nexport function backoffSchedule(maxAttempts = 5): number[] {\n const out: number[] = [];\n for (let i = 1; i <= maxAttempts; i++) out.push(2 ** i * 1000);\n return out;\n}\n\n/** Decide the four steps for one domain from its desired config and live state. */\nexport function planDomain(setup: DomainSetup, state: DomainState): DomainPlan {\n const reminders: string[] = [];\n if (setup.fullUserGroup)\n reminders.push(\n `add ${setup.fullUserGroup} as a Full User in Search Console — no API exists for this, do it in the UI`,\n );\n\n const txt: StepPlan = state.txtPresent\n ? { action: 'skip', detail: 'apex TXT already present' }\n : { action: 'create', detail: 'create apex TXT verification record' };\n\n const verify: StepPlan = state.verified\n ? { action: 'skip', detail: 'ownership already verified' }\n : { action: 'create', detail: 'verify ownership (DNS_TXT)' };\n\n const property: StepPlan = state.propertyExists\n ? { action: 'skip', detail: 'property already exists' }\n : { action: 'create', detail: `add property ${siteUrlFor(setup.domain)}` };\n\n // Owners can only be read once verified; before that, treat every delegated\n // owner as still-to-add so --check reports the drift rather than hiding it.\n const desired = setup.delegatedOwners ?? [];\n const present = new Set(state.currentOwners.map((o) => o.toLowerCase()));\n const ownersToAdd = desired.filter((o) => !present.has(o.toLowerCase()));\n const owners: StepPlan =\n desired.length === 0\n ? { action: 'skip', detail: 'no delegated owners' }\n : ownersToAdd.length === 0\n ? { action: 'skip', detail: 'all delegated owners already present' }\n : {\n action: 'update',\n detail: `add ${ownersToAdd.length} owner(s): ${ownersToAdd.join(', ')}`,\n };\n\n return {\n domain: setup.domain,\n siteUrl: siteUrlFor(setup.domain),\n txt,\n verify,\n property,\n owners,\n ownersToAdd,\n reminders,\n };\n}\n\n/**\n * The owner set to PUT: everyone already there, plus everyone being added.\n *\n * `updateOwners` REPLACES the owner list, so this is the one computation standing\n * between a delegation and locking yourself out — pass a bare `ownersToAdd` and\n * the account that just verified the domain is removed from it. It belongs here\n * rather than at the call site for exactly that reason: it is a decision, it is\n * the dangerous one, and the executor is not allowed to make it.\n *\n * `current` comes FIRST so the API's own casing survives dedupe; matching is\n * case-insensitive, mirroring `planDomain`'s `present` check, because Google\n * echoes back addresses in whatever case the account uses and a case-sensitive\n * `Set` would re-add an owner who is already there.\n */\nexport function ownerUnion(current: string[], toAdd: string[]): string[] {\n const out: string[] = [];\n const seen = new Set<string>();\n for (const o of [...current, ...toAdd]) {\n const key = o.toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n out.push(o);\n }\n return out;\n}\n\n/** Plan every domain. */\nexport function plan(config: OnboardingConfig, states: Map<string, DomainState>): OnboardingPlan {\n const notes: string[] = [];\n const domains = config.domains.map((setup) => {\n const state = states.get(setup.domain);\n if (!state)\n // Should not happen — the CLI observes every domain before planning — but a\n // missing observation must not silently read as \"nothing to do\".\n notes.push(`${setup.domain}: no observed state; treating everything as pending`);\n return planDomain(\n setup,\n state ?? { txtPresent: false, verified: false, currentOwners: [], propertyExists: false },\n );\n });\n return { domains, notes };\n}\n\n/** Any step on any domain is a mutation — i.e. the live state has drifted. */\nexport function hasDrift(p: OnboardingPlan): boolean {\n return p.domains.some(\n (d) =>\n d.txt.action !== 'skip' ||\n d.verify.action !== 'skip' ||\n d.property.action !== 'skip' ||\n d.owners.action !== 'skip',\n );\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: OnboardingPlan): string {\n const lines: string[] = [];\n const glyph = (s: StepPlan) => (s.action === 'skip' ? '·' : '+');\n for (const d of p.domains) {\n lines.push(d.domain);\n lines.push(` ${glyph(d.txt)} TXT ${d.txt.detail}`);\n lines.push(` ${glyph(d.verify)} verify ${d.verify.detail}`);\n lines.push(` ${glyph(d.property)} property ${d.property.detail}`);\n lines.push(` ${glyph(d.owners)} owners ${d.owners.detail}`);\n for (const r of d.reminders) lines.push(` ! ${r}`);\n }\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one domain's run, for the summary table. */\nexport interface DomainResult {\n domain: string;\n txt: 'created' | 'present' | 'failed' | '—';\n verified: 'yes' | 'pending' | 'failed' | '—';\n property: 'added' | 'present' | 'failed' | '—';\n reminders: string[];\n}\n\n/** Render the end-of-run summary: one row per domain, plus any reminders. */\nexport function formatSummary(results: DomainResult[]): string {\n const headers = ['DOMAIN', 'TXT', 'VERIFIED', 'PROPERTY'];\n const rows = results.map((r) => [r.domain, r.txt, r.verified, r.property]);\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n return table(headers, rows, reminders);\n}\n","/**\n * Google — the two APIs onboarding drives, and the user-OAuth token behind both.\n *\n * Auth here is a **user OAuth refresh token**, not a service account: verifying a\n * site and adding a Search Console property act *as a person* who can own them, not\n * as a project robot. That makes the token exchange simpler than the indexing\n * module's — a `refresh_token` grant, no JWT and no `node:crypto` — but it needs\n * broader scope, granted when the refresh token is minted:\n * - `https://www.googleapis.com/auth/siteverification` (Site Verification API)\n * - `https://www.googleapis.com/auth/webmasters` (Search Console API)\n *\n * The exchange itself lives in `../google/token.ts`, shared with `indexing/gsc.ts`\n * — the grants differ, everything after building the form body did not. It throws\n * (nothing downstream can run without a token) while every API call below returns\n * a structured result and lets the caller decide per domain. `googleapis` is\n * deliberately not a dependency — these are a handful of REST endpoints in a CLI\n * that installs into every consumer.\n */\nimport {\n encodeSite,\n errorDetail,\n googleAccessToken,\n googleHeaders,\n type GoogleAuthLike,\n type GoogleOAuth,\n} from '../google/token.ts';\n\nconst SITEVERIFICATION = 'https://www.googleapis.com/siteVerification/v1';\nconst WEBMASTERS = 'https://www.googleapis.com/webmasters/v3';\n\n/** Exchange a user OAuth refresh token for a short-lived access token. */\nexport function refreshTokenGrant(\n oauth: GoogleOAuth,\n fetchImpl: typeof fetch = fetch,\n): Promise<string> {\n return googleAccessToken({ kind: 'oauth', oauth }, fetchImpl);\n}\n\nconst inetSite = (domain: string) => ({ type: 'INET_DOMAIN', identifier: domain });\n\n/**\n * Ask the Site Verification API for the DNS_TXT token for a domain.\n *\n * Returns the full `google-site-verification=…` value that goes in the apex TXT\n * record. It is deterministic per (user, domain), so re-fetching it and comparing\n * against DNS is what makes the TXT step idempotent.\n */\nexport async function getVerificationToken(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; token?: string; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/token`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ verificationMethod: 'DNS_TXT', site: inetSite(domain) }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n const body = (await res.json()) as { token?: string };\n if (!body.token) return { ok: false, status: res.status, message: 'no token in response' };\n return { ok: true, status: res.status, token: body.token };\n}\n\n/**\n * Look up the verified web resource for a domain, if any.\n *\n * Uses `webResource.list` rather than a `get` by id, because the id format for an\n * INET_DOMAIN resource is awkward to construct by hand and the list only ever\n * contains resources the user already owns — so a hit *is* the verified state, and\n * carries the id and owners the later steps need.\n */\nexport async function getWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{\n ok: boolean;\n status: number;\n exists: boolean;\n id?: string;\n owners: string[];\n message?: string;\n}> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, {\n headers: googleHeaders(auth),\n });\n if (!res.ok)\n return { ok: false, status: res.status, exists: false, owners: [], message: await res.text() };\n const body = (await res.json()) as {\n items?: { id?: string; site?: { type?: string; identifier?: string }; owners?: string[] }[];\n };\n const match = body.items?.find(\n (it) => it.site?.type === 'INET_DOMAIN' && it.site?.identifier === domain,\n );\n if (!match) return { ok: true, status: res.status, exists: false, owners: [] };\n return {\n ok: true,\n status: res.status,\n exists: true,\n ...(match.id ? { id: match.id } : {}),\n owners: match.owners ?? [],\n };\n}\n\n/** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */\nexport async function verifyWebResource(\n auth: GoogleAuthLike,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; id?: string; owners: string[]; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {\n method: 'POST',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ site: inetSite(domain), verificationMethod: 'DNS_TXT' }),\n });\n if (!res.ok) return { ok: false, status: res.status, owners: [], message: await res.text() };\n const body = (await res.json()) as { id?: string; owners?: string[] };\n return {\n ok: true,\n status: res.status,\n ...(body.id ? { id: body.id } : {}),\n owners: body.owners ?? [],\n };\n}\n\n/**\n * Set the owners of a verified web resource to a given list.\n *\n * The API replaces the owner set, so the caller passes the **union** of the\n * current owners and the ones to add — never a bare list of new owners, which\n * would drop everyone already there.\n */\nexport async function updateOwners(\n auth: GoogleAuthLike,\n id: string,\n domain: string,\n owners: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {\n method: 'PUT',\n headers: googleHeaders(auth, { 'content-type': 'application/json' }),\n body: JSON.stringify({ id, site: inetSite(domain), owners }),\n });\n if (!res.ok) return { ok: false, status: res.status, message: await res.text() };\n return { ok: true, status: res.status };\n}\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; exists: boolean; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status, exists: true };\n if (res.status === 404) return { ok: true, status: res.status, exists: false };\n return { ok: false, status: res.status, exists: false, message: await res.text() };\n}\n\n/**\n * Add a Search Console property (`PUT sites/{siteUrl}`).\n *\n * A `403` here almost always means the domain is not yet verified for this user\n * rather than a transient error — property creation is gated on ownership.\n */\nexport async function addSite(\n auth: GoogleAuthLike,\n siteUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {\n method: 'PUT',\n headers: googleHeaders(auth),\n });\n if (res.ok) return { ok: true, status: res.status };\n const message = await errorDetail(res, (status) =>\n status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : undefined,\n );\n return { ok: false, status: res.status, message };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/plan-format.ts
|
|
2
|
+
/**
|
|
3
|
+
* The end-of-run summary table shared by `onboarding/plan.ts`'s and `ads/plan.ts`'s
|
|
4
|
+
* `formatSummary` — same header row, same column padding, same "Reminders
|
|
5
|
+
* (manual — no API):" tail. Their `StepAction`/`StepPlan`/`formatPlan` glyph
|
|
6
|
+
* renderers stay separate: onboarding's steps are `create | update | skip`
|
|
7
|
+
* with no `blocked` state, ads' are `create | skip | blocked` with a `needs`
|
|
8
|
+
* field, and collapsing those two shapes into one would blur a real
|
|
9
|
+
* difference rather than remove duplication.
|
|
10
|
+
*/
|
|
11
|
+
const pad = (s, n) => s.padEnd(n);
|
|
12
|
+
/** Render a fixed-width table, then a "Reminders" section if any were passed. */
|
|
13
|
+
function table(headers, rows, reminders) {
|
|
14
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
|
|
15
|
+
const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
|
|
16
|
+
const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
|
|
17
|
+
for (const row of rows) out.push(line(row));
|
|
18
|
+
if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
|
|
19
|
+
return out.join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { table as t };
|
|
24
|
+
//# sourceMappingURL=plan-format-A18SvJ7C.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-format-A18SvJ7C.mjs","names":[],"sources":["../src/plan-format.ts"],"sourcesContent":["/**\n * The end-of-run summary table shared by `onboarding/plan.ts`'s and `ads/plan.ts`'s\n * `formatSummary` — same header row, same column padding, same \"Reminders\n * (manual — no API):\" tail. Their `StepAction`/`StepPlan`/`formatPlan` glyph\n * renderers stay separate: onboarding's steps are `create | update | skip`\n * with no `blocked` state, ads' are `create | skip | blocked` with a `needs`\n * field, and collapsing those two shapes into one would blur a real\n * difference rather than remove duplication.\n */\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render a fixed-width table, then a \"Reminders\" section if any were passed. */\nexport function table(headers: string[], rows: string[][], reminders: string[]): string {\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n if (reminders.length) out.push('', 'Reminders (manual — no API):', ...reminders);\n return out.join('\\n');\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,OAAO,GAAW,MAAc,EAAE,OAAO,CAAC;;AAGhD,SAAgB,MAAM,SAAmB,MAAkB,WAA6B;CACtF,MAAM,SAAS,QAAQ,KAAK,GAAG,MAC7B,KAAK,IAAI,EAAE,QAAQ,GAAG,KAAK,KAAK,QAAQ,IAAI,EAAE,EAAE,UAAU,CAAC,CAAC,CAC9D;CACA,MAAM,QAAQ,UACZ,MACG,KAAK,GAAG,MAAM,IAAI,GAAG,OAAO,MAAM,CAAC,CAAC,CAAC,CACrC,KAAK,IAAI,CAAC,CACV,QAAQ;CAEb,MAAM,MAAM,CAAC,KAAK,OAAO,GAAG,OAAO,KAAK,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;CACvE,KAAK,MAAM,OAAO,MAAM,IAAI,KAAK,KAAK,GAAG,CAAC;CAC1C,IAAI,UAAU,QAAQ,IAAI,KAAK,IAAI,gCAAgC,GAAG,SAAS;CAC/E,OAAO,IAAI,KAAK,IAAI;AACtB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/utils",
|
|
3
|
-
"version": "8.0
|
|
3
|
+
"version": "8.1.0",
|
|
4
4
|
"description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
|
|
5
5
|
"homepage": "https://docs.vitops.ca",
|
|
6
6
|
"bugs": "https://github.com/getvitops/design-system/issues",
|
|
@@ -65,6 +65,10 @@
|
|
|
65
65
|
"./schema": {
|
|
66
66
|
"types": "./dist/schema/index.d.mts",
|
|
67
67
|
"import": "./dist/schema/index.mjs"
|
|
68
|
+
},
|
|
69
|
+
"./hosting": {
|
|
70
|
+
"types": "./dist/hosting/index.d.mts",
|
|
71
|
+
"import": "./dist/hosting/index.mjs"
|
|
68
72
|
}
|
|
69
73
|
},
|
|
70
74
|
"publishConfig": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cloudflare-CgAsXiqe.mjs","names":[],"sources":["../src/plan-format.ts","../src/onboarding/cloudflare.ts"],"sourcesContent":["/**\n * The end-of-run summary table shared by `onboarding/plan.ts`'s and `ads/plan.ts`'s\n * `formatSummary` — same header row, same column padding, same \"Reminders\n * (manual — no API):\" tail. Their `StepAction`/`StepPlan`/`formatPlan` glyph\n * renderers stay separate: onboarding's steps are `create | update | skip`\n * with no `blocked` state, ads' are `create | skip | blocked` with a `needs`\n * field, and collapsing those two shapes into one would blur a real\n * difference rather than remove duplication.\n */\nconst pad = (s: string, n: number) => s.padEnd(n);\n\n/** Render a fixed-width table, then a \"Reminders\" section if any were passed. */\nexport function table(headers: string[], rows: string[][], reminders: string[]): string {\n const widths = headers.map((h, i) =>\n Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)),\n );\n const line = (cells: string[]) =>\n cells\n .map((c, i) => pad(c, widths[i] ?? 0))\n .join(' ')\n .trimEnd();\n\n const out = [line(headers), widths.map((w) => '─'.repeat(w)).join(' ')];\n for (const row of rows) out.push(line(row));\n if (reminders.length) out.push('', 'Reminders (manual — no API):', ...reminders);\n return out.join('\\n');\n}\n","/**\n * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (!zone)\n return {\n ok: false,\n status: res.status,\n // Name the scope: this lookup is a GET /zones?name=, which needs\n // `Zone:Read` — a token carrying only `Zone:DNS:Edit` reaches here and\n // reads as \"the zone isn't in this account\", which sends you to the\n // wrong dashboard.\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The \"Edit zone DNS\" template covers both.)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,OAAO,GAAW,MAAc,EAAE,OAAO,CAAC;;AAGhD,SAAgB,MAAM,SAAmB,MAAkB,WAA6B;CACtF,MAAM,SAAS,QAAQ,KAAK,GAAG,MAC7B,KAAK,IAAI,EAAE,QAAQ,GAAG,KAAK,KAAK,QAAQ,IAAI,EAAE,EAAE,UAAU,CAAC,CAAC,CAC9D;CACA,MAAM,QAAQ,UACZ,MACG,KAAK,GAAG,MAAM,IAAI,GAAG,OAAO,MAAM,CAAC,CAAC,CAAC,CACrC,KAAK,IAAI,CAAC,CACV,QAAQ;CAEb,MAAM,MAAM,CAAC,KAAK,OAAO,GAAG,OAAO,KAAK,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;CACvE,KAAK,MAAM,OAAO,MAAM,IAAI,KAAK,KAAK,GAAG,CAAC;CAC1C,IAAI,UAAU,QAAQ,IAAI,KAAK,IAAI,gCAAgC,GAAG,SAAS;CAC/E,OAAO,IAAI,KAAK,IAAI;AACtB;;;;;;;;;;;;;;;;;;;;ACVA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EAKZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC"}
|