@getvitops/utils 6.0.0 → 8.0.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +248 -13
  2. package/dist/adc-BFrzpMHq.d.mts +25 -0
  3. package/dist/adc-BFrzpMHq.d.mts.map +1 -0
  4. package/dist/adc-D6aw2qgq.mjs +45 -0
  5. package/dist/adc-D6aw2qgq.mjs.map +1 -0
  6. package/dist/ads/index.d.mts +0 -1
  7. package/dist/ads/index.d.mts.map +1 -1
  8. package/dist/ads/index.mjs +4 -13
  9. package/dist/ads/index.mjs.map +1 -1
  10. package/dist/{cloudflare-CD8vwrgj.mjs → cloudflare-CgAsXiqe.mjs} +24 -2
  11. package/dist/cloudflare-CgAsXiqe.mjs.map +1 -0
  12. package/dist/color/index.d.mts +0 -3
  13. package/dist/color/index.d.mts.map +1 -1
  14. package/dist/color/index.mjs +0 -3
  15. package/dist/color/index.mjs.map +1 -1
  16. package/dist/index.d.mts +5 -101
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +5 -145
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/indexing/index.d.mts +5 -6
  21. package/dist/indexing/index.d.mts.map +1 -1
  22. package/dist/indexing/index.mjs +12 -34
  23. package/dist/indexing/index.mjs.map +1 -1
  24. package/dist/json-store-30CFCbZZ.mjs +38 -0
  25. package/dist/json-store-30CFCbZZ.mjs.map +1 -0
  26. package/dist/media/index.d.mts +0 -2
  27. package/dist/media/index.d.mts.map +1 -1
  28. package/dist/media/index.mjs +4 -20
  29. package/dist/media/index.mjs.map +1 -1
  30. package/dist/notify/index.d.mts +1 -2
  31. package/dist/notify/index.d.mts.map +1 -1
  32. package/dist/onboarding/index.d.mts +7 -7
  33. package/dist/onboarding/index.d.mts.map +1 -1
  34. package/dist/onboarding/index.mjs +19 -38
  35. package/dist/onboarding/index.mjs.map +1 -1
  36. package/dist/reviews/index.d.mts +154 -0
  37. package/dist/reviews/index.d.mts.map +1 -0
  38. package/dist/reviews/index.mjs +218 -0
  39. package/dist/reviews/index.mjs.map +1 -0
  40. package/dist/schema/index.d.mts +191 -0
  41. package/dist/schema/index.d.mts.map +1 -0
  42. package/dist/schema/index.mjs +241 -0
  43. package/dist/schema/index.mjs.map +1 -0
  44. package/dist/security/index.d.mts +48 -0
  45. package/dist/security/index.d.mts.map +1 -0
  46. package/dist/security/index.mjs +80 -0
  47. package/dist/security/index.mjs.map +1 -0
  48. package/dist/token-Bz_-igw-.d.mts +74 -0
  49. package/dist/{token--18wN9UR.d.mts.map → token-Bz_-igw-.d.mts.map} +1 -1
  50. package/dist/{token-BV7tPHh2.mjs → token-DkKC5m23.mjs} +25 -3
  51. package/dist/{token-BV7tPHh2.mjs.map → token-DkKC5m23.mjs.map} +1 -1
  52. package/dist/tracking/index.d.mts +1 -1
  53. package/dist/{types-C3lHaSrU.d.mts → types-CHHjzRrv.d.mts} +1 -2
  54. package/dist/types-CHHjzRrv.d.mts.map +1 -0
  55. package/package.json +25 -2
  56. package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
  57. package/dist/token--18wN9UR.d.mts +0 -46
  58. package/dist/types-C3lHaSrU.d.mts.map +0 -1
@@ -1,5 +1,5 @@
1
- import { n as googleAccessToken } from "../token-BV7tPHh2.mjs";
2
- import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
1
+ import { a as googleHeaders, i as googleAccessToken, n as encodeSite, r as errorDetail } from "../token-DkKC5m23.mjs";
2
+ import { i as table, n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CgAsXiqe.mjs";
3
3
 
4
4
  //#region src/onboarding/plan.ts
5
5
  /** The `sc-domain:` property identifier for a bare domain. */
@@ -128,28 +128,19 @@ function formatPlan(p) {
128
128
  for (const n of p.notes) lines.push(`! ${n}`);
129
129
  return lines.join("\n");
130
130
  }
131
- const pad = (s, n) => s.padEnd(n);
132
131
  /** Render the end-of-run summary: one row per domain, plus any reminders. */
133
132
  function formatSummary(results) {
134
- const headers = [
133
+ return table([
135
134
  "DOMAIN",
136
135
  "TXT",
137
136
  "VERIFIED",
138
137
  "PROPERTY"
139
- ];
140
- const rows = results.map((r) => [
138
+ ], results.map((r) => [
141
139
  r.domain,
142
140
  r.txt,
143
141
  r.verified,
144
142
  r.property
145
- ]);
146
- const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
147
- const line = (cells) => cells.map((c, i) => pad(c, widths[i] ?? 0)).join(" ").trimEnd();
148
- const out = [line(headers), widths.map((w) => "─".repeat(w)).join(" ")];
149
- for (const row of rows) out.push(line(row));
150
- const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));
151
- if (reminders.length) out.push("", "Reminders (manual — no API):", ...reminders);
152
- return out.join("\n");
143
+ ]), results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`)));
153
144
  }
154
145
 
155
146
  //#endregion
@@ -192,13 +183,10 @@ const inetSite = (domain) => ({
192
183
  * record. It is deterministic per (user, domain), so re-fetching it and comparing
193
184
  * against DNS is what makes the TXT step idempotent.
194
185
  */
195
- async function getVerificationToken(token, domain, fetchImpl = fetch) {
186
+ async function getVerificationToken(auth, domain, fetchImpl = fetch) {
196
187
  const res = await fetchImpl(`${SITEVERIFICATION}/token`, {
197
188
  method: "POST",
198
- headers: {
199
- authorization: `Bearer ${token}`,
200
- "content-type": "application/json"
201
- },
189
+ headers: googleHeaders(auth, { "content-type": "application/json" }),
202
190
  body: JSON.stringify({
203
191
  verificationMethod: "DNS_TXT",
204
192
  site: inetSite(domain)
@@ -229,8 +217,8 @@ async function getVerificationToken(token, domain, fetchImpl = fetch) {
229
217
  * contains resources the user already owns — so a hit *is* the verified state, and
230
218
  * carries the id and owners the later steps need.
231
219
  */
232
- async function getWebResource(token, domain, fetchImpl = fetch) {
233
- const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers: { authorization: `Bearer ${token}` } });
220
+ async function getWebResource(auth, domain, fetchImpl = fetch) {
221
+ const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers: googleHeaders(auth) });
234
222
  if (!res.ok) return {
235
223
  ok: false,
236
224
  status: res.status,
@@ -254,13 +242,10 @@ async function getWebResource(token, domain, fetchImpl = fetch) {
254
242
  };
255
243
  }
256
244
  /** Verify ownership (DNS_TXT). Succeeds only once the TXT record has propagated. */
257
- async function verifyWebResource(token, domain, fetchImpl = fetch) {
245
+ async function verifyWebResource(auth, domain, fetchImpl = fetch) {
258
246
  const res = await fetchImpl(`${SITEVERIFICATION}/webResource?verificationMethod=DNS_TXT`, {
259
247
  method: "POST",
260
- headers: {
261
- authorization: `Bearer ${token}`,
262
- "content-type": "application/json"
263
- },
248
+ headers: googleHeaders(auth, { "content-type": "application/json" }),
264
249
  body: JSON.stringify({
265
250
  site: inetSite(domain),
266
251
  verificationMethod: "DNS_TXT"
@@ -287,13 +272,10 @@ async function verifyWebResource(token, domain, fetchImpl = fetch) {
287
272
  * current owners and the ones to add — never a bare list of new owners, which
288
273
  * would drop everyone already there.
289
274
  */
290
- async function updateOwners(token, id, domain, owners, fetchImpl = fetch) {
275
+ async function updateOwners(auth, id, domain, owners, fetchImpl = fetch) {
291
276
  const res = await fetchImpl(`${SITEVERIFICATION}/webResource/${encodeURIComponent(id)}`, {
292
277
  method: "PUT",
293
- headers: {
294
- authorization: `Bearer ${token}`,
295
- "content-type": "application/json"
296
- },
278
+ headers: googleHeaders(auth, { "content-type": "application/json" }),
297
279
  body: JSON.stringify({
298
280
  id,
299
281
  site: inetSite(domain),
@@ -310,10 +292,9 @@ async function updateOwners(token, id, domain, owners, fetchImpl = fetch) {
310
292
  status: res.status
311
293
  };
312
294
  }
313
- const encodeSite = (siteUrl) => encodeURIComponent(siteUrl);
314
295
  /** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */
315
- async function getSite(token, siteUrl, fetchImpl = fetch) {
316
- const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: { authorization: `Bearer ${token}` } });
296
+ async function getSite(auth, siteUrl, fetchImpl = fetch) {
297
+ const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers: googleHeaders(auth) });
317
298
  if (res.ok) return {
318
299
  ok: true,
319
300
  status: res.status,
@@ -337,20 +318,20 @@ async function getSite(token, siteUrl, fetchImpl = fetch) {
337
318
  * A `403` here almost always means the domain is not yet verified for this user
338
319
  * rather than a transient error — property creation is gated on ownership.
339
320
  */
340
- async function addSite(token, siteUrl, fetchImpl = fetch) {
321
+ async function addSite(auth, siteUrl, fetchImpl = fetch) {
341
322
  const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {
342
323
  method: "PUT",
343
- headers: { authorization: `Bearer ${token}` }
324
+ headers: googleHeaders(auth)
344
325
  });
345
326
  if (res.ok) return {
346
327
  ok: true,
347
328
  status: res.status
348
329
  };
349
- const detail = res.status === 403 ? `403 — "${siteUrl}" is not verified for this account yet, or the OAuth token lacks the webmasters scope` : `${res.status}: ${await res.text()}`;
330
+ const message = await errorDetail(res, (status) => status === 403 ? `403 — "${siteUrl}" is not verified for this account yet, or the OAuth token lacks the webmasters scope` : void 0);
350
331
  return {
351
332
  ok: false,
352
333
  status: res.status,
353
- message: detail
334
+ message
354
335
  };
355
336
  }
356
337
 
@@ -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';\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\nconst pad = (s: string, n: number) => s.padEnd(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 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\n const reminders = results.flatMap((r) => r.reminders.map((m) => ` ! ${r.domain}: ${m}`));\n if (reminders.length) {\n out.push('', 'Reminders (manual — no API):', ...reminders);\n }\n return out.join('\\n');\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 { googleAccessToken, type GoogleOAuth } 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 token: string,\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: { authorization: `Bearer ${token}`, '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 token: string,\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: { authorization: `Bearer ${token}` },\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 token: string,\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: { authorization: `Bearer ${token}`, '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 token: string,\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: { authorization: `Bearer ${token}`, '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\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/** Whether a Search Console property already exists (`GET sites/{siteUrl}`). */\nexport async function getSite(\n token: string,\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: { authorization: `Bearer ${token}` },\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 token: string,\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: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — \"${siteUrl}\" is not verified for this account yet, or the OAuth token lacks the webmasters scope`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\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,154 @@
1
+ import { a as SCOPES, c as googleHeaders, i as GoogleOAuth, n as GoogleAuthLike, r as GoogleCredential, s as googleAccessToken, t as GoogleAuth } from "../token-Bz_-igw-.mjs";
2
+ import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-BFrzpMHq.mjs";
3
+
4
+ //#region src/reviews/types.d.ts
5
+ /**
6
+ * The reviews module's own option surface.
7
+ *
8
+ * Deliberately structural rather than `Config['site']['reviews']` imported from
9
+ * `@getvitops/generator` — the generator already depends on this package, so
10
+ * importing back would be a cycle. The vocabulary mirrors that block field for
11
+ * field, the same arrangement as `IndexingConfig` in `../indexing/types.ts`.
12
+ */
13
+ /** How one location's reviews should be selected and ordered for display. */
14
+ interface ReviewLocationConfig {
15
+ /** GBP account resource id (`accounts/…`), overriding the top-level default. */
16
+ accountId?: string | undefined;
17
+ /** GBP location resource id (`locations/…`) — required to fetch this location. */
18
+ locationId: string;
19
+ /** Explicit review ids, in order. These are always included, ahead of everything else. */
20
+ featured?: string[] | undefined;
21
+ /** Drop reviews below this star rating (default 4). */
22
+ minRating?: number | undefined;
23
+ /** Keep at most this many reviews after filtering. */
24
+ limit?: number | undefined;
25
+ /** Ordering for the non-featured remainder (default `recent`). */
26
+ sort?: 'recent' | 'rating' | undefined;
27
+ }
28
+ /** Everything `plan()` needs to decide what to fetch. */
29
+ interface ReviewsConfig {
30
+ /** Default GBP account resource id for locations that don't override it. */
31
+ accountId?: string | undefined;
32
+ /** Keyed by `organization.locations[].key`. */
33
+ locations: Record<string, ReviewLocationConfig>;
34
+ }
35
+ /** One review, as GBP returns it and the cache stores it. */
36
+ interface Review {
37
+ id: string;
38
+ /** The `organization.locations[]` key this review belongs to. */
39
+ location: string;
40
+ /** 1–5. */
41
+ rating: number;
42
+ author: string;
43
+ authorPhoto?: string | undefined;
44
+ text?: string | undefined;
45
+ publishedAt: string;
46
+ updatedAt: string;
47
+ reply?: {
48
+ text: string;
49
+ at: string;
50
+ } | undefined;
51
+ /** Link back to the review on Google — never omit when linking to it. */
52
+ url?: string | undefined;
53
+ }
54
+ /** What a fetch of one location produced. */
55
+ interface LocationReviews {
56
+ locationId: string;
57
+ accountId: string;
58
+ fetchedAt: string;
59
+ averageRating?: number | undefined;
60
+ totalReviewCount?: number | undefined;
61
+ reviews: Review[];
62
+ }
63
+ /** The on-disk cache, as written by `writeCache`. */
64
+ interface ReviewCache {
65
+ version: 1;
66
+ locations: Record<string, LocationReviews>;
67
+ }
68
+ //#endregion
69
+ //#region src/reviews/plan.d.ts
70
+ /** One location's fetch plan, or the reason it was skipped. */
71
+ interface LocationPlan {
72
+ key: string;
73
+ accountId?: string;
74
+ locationId?: string;
75
+ /** Present iff this location will not be fetched. */
76
+ skip?: string;
77
+ }
78
+ interface ReviewsPlan {
79
+ /** Set when the whole run is refused — nothing is fetched. */
80
+ blocked?: string;
81
+ locations: LocationPlan[];
82
+ notes: string[];
83
+ }
84
+ interface PlanInput {
85
+ config: ReviewsConfig | undefined;
86
+ /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
87
+ knownLocations: Set<string>;
88
+ /** `--location`: fetch exactly this one, skipping the rest. */
89
+ onlyLocation?: string | undefined;
90
+ /** Whether a Google credential was resolved. */
91
+ hasCredential: boolean;
92
+ }
93
+ /** Decide which locations to fetch, and why any are skipped. */
94
+ declare function plan(input: PlanInput): ReviewsPlan;
95
+ /** Render a plan as the human-readable block `--dry` prints. */
96
+ declare function formatPlan(p: ReviewsPlan): string;
97
+ /**
98
+ * Filter and order one location's reviews for display.
99
+ *
100
+ * Featured ids always win and keep the author's order — an explicit choice
101
+ * overrides any automatic ranking. The remainder is filtered by `minRating`
102
+ * (default 4 — a star-rating widget showing a 1-star review it did not have to
103
+ * is a design choice a config field should not have to spell out every time),
104
+ * sorted, then capped by `limit`.
105
+ */
106
+ declare function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[];
107
+ /**
108
+ * Merge a fresh fetch into the existing cache.
109
+ *
110
+ * Only the locations actually fetched this run are replaced; every other
111
+ * location's prior reviews pass through untouched. This is what makes a partial
112
+ * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
113
+ * only the locations that succeeded and leaves the rest of the cache alone.
114
+ */
115
+ declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
116
+ //#endregion
117
+ //#region src/reviews/cache.d.ts
118
+ /** Default location, relative to the consumer's project root. */
119
+ declare const REVIEWS_CACHE_PATH = ".vitops/reviews.json";
120
+ /**
121
+ * Read the cache, or `undefined` when there isn't a usable one.
122
+ *
123
+ * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:
124
+ * the alternative — reading it as an empty cache — would be indistinguishable
125
+ * from "this business genuinely has no reviews" and silently ship an empty
126
+ * carousel.
127
+ */
128
+ declare function readCache(path: string): ReviewCache | undefined;
129
+ /** Build a cache with its keys and each location's reviews in a stable order. */
130
+ declare function toCache(locations: Record<string, LocationReviews>): ReviewCache;
131
+ /** Write the cache, creating its directory. */
132
+ declare function writeCache(path: string, cache: ReviewCache): void;
133
+ //#endregion
134
+ //#region src/reviews/gbp.d.ts
135
+ /** One page (or, having paginated, the whole set) of a location's reviews. */
136
+ interface FetchReviewsResult {
137
+ ok: boolean;
138
+ reviews: Review[];
139
+ averageRating?: number;
140
+ totalReviewCount?: number;
141
+ status?: number;
142
+ message?: string;
143
+ }
144
+ /**
145
+ * Fetch every review for one location, paginating to completion.
146
+ *
147
+ * Never throws — a per-location failure is a structured result so a run across
148
+ * several locations can fetch what it can and report the rest, the same contract
149
+ * `../indexing/gsc.ts`'s `inspectUrl` keeps.
150
+ */
151
+ declare function fetchReviews(auth: GoogleAuthLike, accountId: string, locationId: string, fetchImpl?: typeof fetch): Promise<FetchReviewsResult>;
152
+ //#endregion
153
+ export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, REVIEWS_CACHE_PATH, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, fetchReviews, formatPlan, googleAccessToken, googleHeaders, mergeCache, parseAdcUser, plan, readCache, selectReviews, toCache, writeCache };
154
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/types.ts","../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"mappings":";;;;;;;;AAgBA;;;;;UAAiB,oBAAA;EAMf;EAJA,SAAA;EAQA;EANA,UAAA;EAQI;EANJ,QAAA;EAUe;EARf,SAAA;;EAEA,KAAA;EAQA;EANA,IAAA;AAAA;;UAIe,aAAA;EAI+B;EAF9C,SAAA;EAMqB;EAJrB,SAAA,EAAW,MAAM,SAAS,oBAAA;AAAA;;UAIX,MAAA;EACf,EAAA;EAKA;EAHA,QAAA;EAKA;EAHA,MAAA;EACA,MAAA;EACA,WAAA;EACA,IAAA;EACA,WAAA;EACA,SAAA;EACA,KAAA;IAAU,IAAA;IAAc,EAAA;EAAA;;EAExB,GAAA;AAAA;;UAIe,eAAA;EACf,UAAA;EACA,SAAA;EACA,SAAA;EACA,aAAA;EACA,gBAAA;EACA,OAAA,EAAS,MAAM;AAAA;;UAIA,WAAA;EACf,OAAA;EACA,SAAA,EAAW,MAAM,SAAS,eAAA;AAAA;;;;UClDX,YAAA;EACf,GAAA;EACA,SAAA;EACA,UAAA;EDUe;ECRf,IAAA;AAAA;AAAA,UAGe,WAAA;EDOf;ECLA,OAAA;EACA,SAAA,EAAW,YAAY;EACvB,KAAA;AAAA;AAAA,UAGe,SAAA;EACf,MAAA,EAAQ,aAAA;EDKa;ECHrB,cAAA,EAAgB,GAAG;EDGE;ECDrB,YAAA;EDIA;ECFA,aAAA;AAAA;;iBAIc,IAAA,CAAK,KAAA,EAAO,SAAA,GAAY,WAAW;;iBAuDnC,UAAA,CAAW,CAAc,EAAX,WAAW;;;;;;AD/CpC;AAIL;;;iBC8DgB,aAAA,CAAc,OAAA,EAAS,MAAA,IAAU,MAAA,EAAQ,oBAAA,GAAuB,MAAA;;;;;;;;;iBA2BhE,UAAA,CACd,QAAA,EAAU,WAAA,cACV,OAAA,EAAS,MAAA,SAAe,eAAA,IACvB,WAAA;;;;cCvIU,kBAAA;;;AFcP;AAIN;;;;;iBERgB,SAAA,CAAU,IAAA,WAAe,WAAW;;iBAKpC,OAAA,CAAQ,SAAA,EAAW,MAAA,SAAe,eAAA,IAAmB,WAAA;;iBAarD,UAAA,CAAW,IAAA,UAAc,KAAA,EAAO,WAAW;;;;UCoB1C,kBAAA;EACf,EAAA;EACA,OAAA,EAAS,MAAM;EACf,aAAA;EACA,gBAAA;EACA,MAAA;EACA,OAAA;AAAA;;;;;;;;iBAUoB,YAAA,CACpB,IAAA,EAAM,cAAA,EACN,SAAA,UACA,UAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAA,CAAQ,kBAAA"}
@@ -0,0 +1,218 @@
1
+ import { n as writeVersioned, t as readVersioned } from "../json-store-30CFCbZZ.mjs";
2
+ import { a as googleHeaders, i as googleAccessToken, t as SCOPES } from "../token-DkKC5m23.mjs";
3
+ import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-D6aw2qgq.mjs";
4
+
5
+ //#region src/reviews/plan.ts
6
+ /** Decide which locations to fetch, and why any are skipped. */
7
+ function plan(input) {
8
+ const { config, knownLocations, onlyLocation, hasCredential } = input;
9
+ const notes = [];
10
+ if (!config || Object.keys(config.locations).length === 0) return {
11
+ blocked: "no site.reviews.locations configured — nothing to fetch",
12
+ locations: [],
13
+ notes
14
+ };
15
+ if (!hasCredential) return {
16
+ blocked: "no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`",
17
+ locations: [],
18
+ notes
19
+ };
20
+ const entries = onlyLocation ? Object.entries(config.locations).filter(([key]) => key === onlyLocation) : Object.entries(config.locations);
21
+ if (onlyLocation && entries.length === 0) return {
22
+ blocked: `--location "${onlyLocation}" is not in site.reviews.locations`,
23
+ locations: [],
24
+ notes
25
+ };
26
+ const locations = entries.map(([key, loc]) => {
27
+ if (!knownLocations.has(key)) return {
28
+ key,
29
+ skip: `organization.locations has no entry keyed "${key}" with a listings.google id`
30
+ };
31
+ const accountId = loc.accountId ?? config.accountId;
32
+ if (!accountId) return {
33
+ key,
34
+ skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`
35
+ };
36
+ return {
37
+ key,
38
+ accountId,
39
+ locationId: loc.locationId
40
+ };
41
+ });
42
+ const blocked = locations.filter((l) => l.skip);
43
+ if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);
44
+ return {
45
+ locations,
46
+ notes
47
+ };
48
+ }
49
+ /** Render a plan as the human-readable block `--dry` prints. */
50
+ function formatPlan(p) {
51
+ if (p.blocked) return `✗ blocked — ${p.blocked}`;
52
+ const lines = [];
53
+ const active = p.locations.filter((l) => !l.skip);
54
+ lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);
55
+ for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);
56
+ for (const n of p.notes) lines.push(` ! ${n}`);
57
+ return lines.join("\n");
58
+ }
59
+ /**
60
+ * Filter and order one location's reviews for display.
61
+ *
62
+ * Featured ids always win and keep the author's order — an explicit choice
63
+ * overrides any automatic ranking. The remainder is filtered by `minRating`
64
+ * (default 4 — a star-rating widget showing a 1-star review it did not have to
65
+ * is a design choice a config field should not have to spell out every time),
66
+ * sorted, then capped by `limit`.
67
+ */
68
+ function selectReviews(reviews, config) {
69
+ const featuredIds = config.featured ?? [];
70
+ const byId = new Map(reviews.map((r) => [r.id, r]));
71
+ const featured = featuredIds.map((id) => byId.get(id)).filter((r) => !!r);
72
+ const featuredSet = new Set(featured.map((r) => r.id));
73
+ const minRating = config.minRating ?? 4;
74
+ const rest = reviews.filter((r) => !featuredSet.has(r.id) && r.rating >= minRating).sort((a, b) => config.sort === "rating" ? b.rating - a.rating : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
75
+ const combined = [...featured, ...rest];
76
+ return config.limit !== void 0 ? combined.slice(0, config.limit) : combined;
77
+ }
78
+ /**
79
+ * Merge a fresh fetch into the existing cache.
80
+ *
81
+ * Only the locations actually fetched this run are replaced; every other
82
+ * location's prior reviews pass through untouched. This is what makes a partial
83
+ * failure (one location's fetch errors) non-destructive — `cmdReviews` merges
84
+ * only the locations that succeeded and leaves the rest of the cache alone.
85
+ */
86
+ function mergeCache(existing, fetched) {
87
+ return {
88
+ version: 1,
89
+ locations: {
90
+ ...existing?.locations,
91
+ ...fetched
92
+ }
93
+ };
94
+ }
95
+
96
+ //#endregion
97
+ //#region src/reviews/cache.ts
98
+ /** Default location, relative to the consumer's project root. */
99
+ const REVIEWS_CACHE_PATH = ".vitops/reviews.json";
100
+ /**
101
+ * Read the cache, or `undefined` when there isn't a usable one.
102
+ *
103
+ * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:
104
+ * the alternative — reading it as an empty cache — would be indistinguishable
105
+ * from "this business genuinely has no reviews" and silently ship an empty
106
+ * carousel.
107
+ */
108
+ function readCache(path) {
109
+ return readVersioned(path, "locations");
110
+ }
111
+ /** Build a cache with its keys and each location's reviews in a stable order. */
112
+ function toCache(locations) {
113
+ const sorted = {};
114
+ for (const key of Object.keys(locations).sort()) {
115
+ const entry = locations[key];
116
+ sorted[key] = {
117
+ ...entry,
118
+ reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id))
119
+ };
120
+ }
121
+ return {
122
+ version: 1,
123
+ locations: sorted
124
+ };
125
+ }
126
+ /** Write the cache, creating its directory. */
127
+ function writeCache(path, cache) {
128
+ writeVersioned(path, cache);
129
+ }
130
+
131
+ //#endregion
132
+ //#region src/reviews/gbp.ts
133
+ /**
134
+ * Google Business Profile — fetching one location's reviews.
135
+ *
136
+ * Reviews live only on the legacy v4 Business Information API
137
+ * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,
138
+ * Business Information v1) never grew a reviews resource. **The calling GCP
139
+ * project must be allowlisted by Google before any call here succeeds** — a
140
+ * one-time access-request form, turnaround measured in days to weeks — so a 403
141
+ * is treated as that known state rather than surfaced as a raw failure. Scope:
142
+ * `SCOPES.businessProfile` (`../google/token.ts`).
143
+ *
144
+ * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a
145
+ * successor API. Endpoint, auth, pagination and the review object's fields may
146
+ * all change; this module is the one place that assumption lives.
147
+ *
148
+ * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`
149
+ * — no `googleapis` dependency, one REST endpoint.
150
+ */
151
+ const STAR_RATING = {
152
+ ONE: 1,
153
+ TWO: 2,
154
+ THREE: 3,
155
+ FOUR: 4,
156
+ FIVE: 5
157
+ };
158
+ function toReview(raw, location) {
159
+ return {
160
+ id: raw.reviewId,
161
+ location,
162
+ rating: STAR_RATING[raw.starRating ?? ""] ?? 0,
163
+ author: raw.reviewer?.isAnonymous ? "A Google user" : raw.reviewer?.displayName ?? "A Google user",
164
+ ...raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {},
165
+ ...raw.comment ? { text: raw.comment } : {},
166
+ publishedAt: raw.createTime,
167
+ updatedAt: raw.updateTime,
168
+ ...raw.reviewReply ? { reply: {
169
+ text: raw.reviewReply.comment,
170
+ at: raw.reviewReply.updateTime
171
+ } } : {}
172
+ };
173
+ }
174
+ /**
175
+ * Fetch every review for one location, paginating to completion.
176
+ *
177
+ * Never throws — a per-location failure is a structured result so a run across
178
+ * several locations can fetch what it can and report the rest, the same contract
179
+ * `../indexing/gsc.ts`'s `inspectUrl` keeps.
180
+ */
181
+ async function fetchReviews(auth, accountId, locationId, fetchImpl = fetch) {
182
+ const location = locationId.startsWith("locations/") ? locationId : `locations/${locationId}`;
183
+ const account = accountId.startsWith("accounts/") ? accountId : `accounts/${accountId}`;
184
+ const reviews = [];
185
+ let averageRating;
186
+ let totalReviewCount;
187
+ let pageToken;
188
+ do {
189
+ const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);
190
+ url.searchParams.set("pageSize", "50");
191
+ if (pageToken) url.searchParams.set("pageToken", pageToken);
192
+ const res = await fetchImpl(url, { headers: googleHeaders(auth) });
193
+ if (!res.ok) {
194
+ const message = res.status === 403 ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet (a one-time Google approval, requested separately from every other API here), or the credential is not an owner/manager of ${account}/${location}` : `${res.status}: ${await res.text()}`;
195
+ return {
196
+ ok: false,
197
+ reviews,
198
+ status: res.status,
199
+ message
200
+ };
201
+ }
202
+ const body = await res.json();
203
+ for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));
204
+ averageRating = body.averageRating ?? averageRating;
205
+ totalReviewCount = body.totalReviewCount ?? totalReviewCount;
206
+ pageToken = body.nextPageToken;
207
+ } while (pageToken);
208
+ return {
209
+ ok: true,
210
+ reviews,
211
+ ...averageRating !== void 0 ? { averageRating } : {},
212
+ ...totalReviewCount !== void 0 ? { totalReviewCount } : {}
213
+ };
214
+ }
215
+
216
+ //#endregion
217
+ export { REVIEWS_CACHE_PATH, SCOPES, adcCredentialsPath, adcQuotaProject, fetchReviews, formatPlan, googleAccessToken, googleHeaders, mergeCache, parseAdcUser, plan, readCache, selectReviews, toCache, writeCache };
218
+ //# sourceMappingURL=index.mjs.map