@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.
- package/CHANGELOG.md +248 -13
- package/dist/adc-BFrzpMHq.d.mts +25 -0
- package/dist/adc-BFrzpMHq.d.mts.map +1 -0
- package/dist/adc-D6aw2qgq.mjs +45 -0
- package/dist/adc-D6aw2qgq.mjs.map +1 -0
- package/dist/ads/index.d.mts +0 -1
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +4 -13
- package/dist/ads/index.mjs.map +1 -1
- package/dist/{cloudflare-CD8vwrgj.mjs → cloudflare-CgAsXiqe.mjs} +24 -2
- package/dist/cloudflare-CgAsXiqe.mjs.map +1 -0
- package/dist/color/index.d.mts +0 -3
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +0 -3
- package/dist/color/index.mjs.map +1 -1
- package/dist/index.d.mts +5 -101
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -145
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +5 -6
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +12 -34
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/json-store-30CFCbZZ.mjs +38 -0
- package/dist/json-store-30CFCbZZ.mjs.map +1 -0
- package/dist/media/index.d.mts +0 -2
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +4 -20
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +1 -2
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/onboarding/index.d.mts +7 -7
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +19 -38
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +154 -0
- package/dist/reviews/index.d.mts.map +1 -0
- package/dist/reviews/index.mjs +218 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/schema/index.d.mts +191 -0
- package/dist/schema/index.d.mts.map +1 -0
- package/dist/schema/index.mjs +241 -0
- package/dist/schema/index.mjs.map +1 -0
- package/dist/security/index.d.mts +48 -0
- package/dist/security/index.d.mts.map +1 -0
- package/dist/security/index.mjs +80 -0
- package/dist/security/index.mjs.map +1 -0
- package/dist/token-Bz_-igw-.d.mts +74 -0
- package/dist/{token--18wN9UR.d.mts.map → token-Bz_-igw-.d.mts.map} +1 -1
- package/dist/{token-BV7tPHh2.mjs → token-DkKC5m23.mjs} +25 -3
- package/dist/{token-BV7tPHh2.mjs.map → token-DkKC5m23.mjs.map} +1 -1
- package/dist/tracking/index.d.mts +1 -1
- package/dist/{types-C3lHaSrU.d.mts → types-CHHjzRrv.d.mts} +1 -2
- package/dist/types-CHHjzRrv.d.mts.map +1 -0
- package/package.json +25 -2
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/token--18wN9UR.d.mts +0 -46
- package/dist/types-C3lHaSrU.d.mts.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
233
|
-
const res = await fetchImpl(`${SITEVERIFICATION}/webResource`, { headers:
|
|
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(
|
|
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(
|
|
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(
|
|
316
|
-
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, { headers:
|
|
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(
|
|
321
|
+
async function addSite(auth, siteUrl, fetchImpl = fetch) {
|
|
341
322
|
const res = await fetchImpl(`${WEBMASTERS}/sites/${encodeSite(siteUrl)}`, {
|
|
342
323
|
method: "PUT",
|
|
343
|
-
headers:
|
|
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
|
|
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
|
|
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
|