From abf53dfcb9ca37a26265d5d6e0ec30997b4c8179 Mon Sep 17 00:00:00 2001 From: ian nuttall <6681919+iannuttall@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:08:14 +0100 Subject: [PATCH] feat(auth): support multiple Google accounts --- .claude-plugin/plugin.json | 2 +- README.md | 9 +- apps/web/src/content/docs/docs/google.mdx | 39 ++- package.json | 2 +- .../commands/analytics/google/properties.ts | 6 +- .../src/commands/analytics/google/report.ts | 9 +- packages/cli/src/commands/auth.test.ts | 71 ++++++ packages/cli/src/commands/auth.ts | 154 ++++++++++-- .../cli/src/commands/clients/profiles/add.ts | 60 ++++- .../src/commands/clients/profiles/output.ts | 8 + packages/cli/src/commands/data.ts | 18 +- packages/cli/src/commands/setup/flow.ts | 39 ++- packages/cli/src/commands/setup/index.ts | 8 + packages/cli/src/commands/setup/prompts.ts | 173 ++++++++++++- packages/cli/src/commands/shared.ts | 2 +- packages/cli/src/commands/system.ts | 9 +- packages/cli/src/help.test.ts | 11 +- .../cli/src/onboarding.integration.test.ts | 74 +++++- packages/cli/src/selection.ts | 27 ++- packages/core/src/clients.test.ts | 49 ++++ packages/core/src/clients.ts | 34 ++- packages/core/src/ga4/client.ts | 20 +- packages/core/src/gsc/auth.ts | 1 + packages/core/src/gsc/auth/account-context.ts | 20 ++ .../src/gsc/auth/account-selection.test.ts | 84 +++++++ .../core/src/gsc/auth/account-selection.ts | 47 ++++ .../src/gsc/auth/authorized-client.test.ts | 58 ++++- .../core/src/gsc/auth/authorized-client.ts | 48 ++-- .../src/gsc/auth/loopback-callback.test.ts | 44 ++++ .../core/src/gsc/auth/loopback-callback.ts | 67 +++++ packages/core/src/gsc/auth/loopback.ts | 58 +---- packages/core/src/gsc/client/fetch.ts | 4 +- packages/core/src/gsc/client/inspection.ts | 5 +- .../core/src/gsc/client/search-analytics.ts | 9 +- packages/core/src/gsc/client/sites.ts | 3 +- packages/core/src/gsc/client/types.ts | 1 + packages/core/src/storage/config.test.ts | 160 +++++++++++- packages/core/src/storage/config.ts | 229 ++++++++++++++---- packages/core/src/types/auth.ts | 38 ++- packages/core/src/types/config.ts | 7 + packages/mcp/src/client-tools.ts | 11 + packages/mcp/src/data-tools.ts | 26 +- 42 files changed, 1514 insertions(+), 230 deletions(-) create mode 100644 packages/core/src/gsc/auth/account-context.ts create mode 100644 packages/core/src/gsc/auth/account-selection.test.ts create mode 100644 packages/core/src/gsc/auth/account-selection.ts create mode 100644 packages/core/src/gsc/auth/loopback-callback.test.ts create mode 100644 packages/core/src/gsc/auth/loopback-callback.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c3d29f45..7ab71f21 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "seo", "displayName": "seo", "description": "Local-first SEO and AI-search diagnostics. Bundles the seo MCP server plus one SEO skill that gives an agent 50+ audit and report tools without filling its context window.", - "version": "0.2.35", + "version": "0.2.36", "author": { "name": "Ian Nuttall" }, diff --git a/README.md b/README.md index 89ef9a35..3f29d3e6 100644 --- a/README.md +++ b/README.md @@ -329,13 +329,18 @@ for setup, costs, caching, country-level limits, competitor classification, and programmatic data-source checks. Traffic analytics commands sit under their provider namespace. Google Analytics -uses the connected Google account: +uses the account saved with the project, or the selected default Google account: ```sh seo analytics google properties seo analytics google report --property 123456789 --dimensions landingPage --metrics sessions,totalUsers ``` +You can keep several Google logins on one machine. Run `seo auth login` again +to add an account, then use `seo auth accounts` to list them. During `seo start` +you can select separate accounts for Search Console and Google Analytics. +Project reports use those saved choices automatically. + Clicky is available through the first-party [Clicky provider package](https://github.com/iannuttall/seoskill-clicky-provider). It supplies landing-page visits and Clicky analytics reports. Its README owns @@ -849,7 +854,7 @@ provider caches are also local. Use these commands to inspect or remove them: ```sh seo privacy seo doctor -seo auth logout +seo auth logout --all seo reset ``` diff --git a/apps/web/src/content/docs/docs/google.mdx b/apps/web/src/content/docs/docs/google.mdx index ae45c46a..0ca9ea9b 100644 --- a/apps/web/src/content/docs/docs/google.mdx +++ b/apps/web/src/content/docs/docs/google.mdx @@ -39,6 +39,37 @@ seo sites seo projects list ``` +## Connect more than one Google account + +Run the login command again to add another account. Earlier logins stay saved +on this machine. + +```sh +seo auth login +seo auth accounts +``` + +During `seo start`, choose one account for Search Console and another account +for Google Analytics if the site needs that split. The project profile keeps +both choices. Reports that use the project then select the correct login for +each data source. + +Agents and CI can make the same choice with explicit flags: + +```sh +seo start \ + --site sc-domain:example.com \ + --search-console-account \ + --google-analytics-property 123456789 \ + --google-analytics-account \ + --json +``` + +Use `seo auth use ` to change the default login for commands that do not +use a saved project. Direct property commands also accept `--account `. +Remove one login with `seo auth logout --account `, or remove every +login with `seo auth logout --all`. + ## Use Search Console to find search demand and affected pages Search Console reports clicks, impressions, CTR, and average position for the @@ -325,8 +356,8 @@ seo auth whoami config directory. Do not commit it to the repository. Environment-based setups can provide `SEO_GOOGLE_CLIENT_ID` and `SEO_GOOGLE_CLIENT_SECRET` instead. -The CLI records which client created a saved login. If you switch from the -shared client to your own client, run `seo auth logout` before signing in again. +The CLI records which client created each saved login. If you switch from the +shared client to your own client, run `seo auth logout --all` before signing in again. `seo auth status` shows whether the active login uses the shared SEO Skill app or your own client. @@ -435,11 +466,11 @@ credential configuration before a report runs. ## Remove access and local tokens ```sh -seo auth logout +seo auth logout --all seo privacy ``` -Logout deletes locally stored Google tokens. It does not delete your Search +This command deletes all locally stored Google tokens. It does not delete your Search Console property or Google Analytics property. The [privacy policy](/privacy) documents the local paths and network requests, while the [setup guide](/docs/getting-started) covers project profiles and first-run troubleshooting. diff --git a/package.json b/package.json index a823fd86..9b8249c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seo", - "version": "0.2.35", + "version": "0.2.36", "description": "The SEO command for AI agents. Audit sites and research search opportunities with local, evidence-backed reports.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/commands/analytics/google/properties.ts b/packages/cli/src/commands/analytics/google/properties.ts index 0a54911c..d39bb89a 100644 --- a/packages/cli/src/commands/analytics/google/properties.ts +++ b/packages/cli/src/commands/analytics/google/properties.ts @@ -24,9 +24,13 @@ export const googleAnalyticsPropertiesCommand = defineCommand({ description: 'Save this numeric Google Analytics property ID as the default.', }, + account: { + type: 'string', + description: 'Saved Google account email.', + }, }, run: async ({ args }) => { - const summaries = await listGa4AccountSummaries() + const summaries = await listGa4AccountSummaries(stringArg(args.account)) const rows = summaries.flatMap((account) => account.propertySummaries.map((property) => ({ account: account.displayName ?? account.account, diff --git a/packages/cli/src/commands/analytics/google/report.ts b/packages/cli/src/commands/analytics/google/report.ts index c958d904..2d47eb2c 100644 --- a/packages/cli/src/commands/analytics/google/report.ts +++ b/packages/cli/src/commands/analytics/google/report.ts @@ -28,7 +28,7 @@ export async function resolveGoogleAnalyticsReportProperty( input: { property?: string project?: string - options?: { json?: boolean } + options?: { json?: boolean; account?: string } }, dependencies: GoogleAnalyticsPropertySelectionDependencies = { resolveClient, @@ -72,6 +72,10 @@ export const googleAnalyticsReportCommand = defineCommand({ description: 'Saved project id or name with an optional Google Analytics property.', }, + account: { + type: 'string', + description: 'Saved Google account email.', + }, 'start-date': { type: 'string', default: '28daysAgo' }, 'end-date': { type: 'string', default: 'yesterday' }, dimensions: { type: 'string', default: 'landingPage' }, @@ -91,7 +95,7 @@ export const googleAnalyticsReportCommand = defineCommand({ const property = await resolveGoogleAnalyticsReportProperty({ property: stringArg(args.property), project: projectArg(args), - options: { json }, + options: { json, account: stringArg(args.account) }, }) const body = (await jsonBodyArg(args.body, args['body-file'])) ?? @@ -108,6 +112,7 @@ export const googleAnalyticsReportCommand = defineCommand({ } as Record) const result = await runGa4Report(property, body as never, { refresh: booleanArg(args.refresh), + accountEmail: stringArg(args.account), }) if (json) { printJson(result) diff --git a/packages/cli/src/commands/auth.test.ts b/packages/cli/src/commands/auth.test.ts index a2ce2583..7c7a2b3e 100644 --- a/packages/cli/src/commands/auth.test.ts +++ b/packages/cli/src/commands/auth.test.ts @@ -90,3 +90,74 @@ test('auth whoami prints a readable account summary', async () => { await rm(cacheDir, { recursive: true, force: true }) } }) + +test('auth lists and selects saved Google accounts', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'seo-accounts-config-')) + const cacheDir = await mkdtemp(join(tmpdir(), 'seo-accounts-cache-')) + const env = { SEO_CONFIG_DIR: configDir, SEO_CACHE_DIR: cacheDir } + const token = (accountEmail: string) => ({ + provider: 'google', + account_email: accountEmail, + scope: 'openid email', + token_type: 'Bearer', + access_token: `${accountEmail}-access`, + refresh_token: `${accountEmail}-refresh`, + expires_at: Date.now() + 3_600_000, + obtained_at: Date.now(), + client_source: 'shared', + }) + + try { + await writeFile( + join(configDir, 'config.json'), + JSON.stringify({ security: { useKeychain: false } }), + ) + await writeFile( + join(configDir, 'tokens.json'), + JSON.stringify({ + version: 2, + active_account: 'first@example.com', + accounts: [token('first@example.com'), token('second@example.com')], + }), + ) + + const listed = await runSeo(['auth', 'accounts', '--json'], env) + assert.equal(listed.exitCode, 0) + assert.deepEqual( + JSON.parse(listed.stdout).accounts.map( + (account: { accountEmail: string; active: boolean }) => ({ + email: account.accountEmail, + active: account.active, + }), + ), + [ + { email: 'first@example.com', active: true }, + { email: 'second@example.com', active: false }, + ], + ) + + const selected = await runSeo(['auth', 'use', 'second@example.com'], env) + assert.equal(selected.exitCode, 0) + + const whoami = await runSeo(['auth', 'whoami', '--json'], env) + assert.equal(JSON.parse(whoami.stdout).account, 'second@example.com') + + const loggedOut = await runSeo(['auth', 'logout'], env) + assert.equal(loggedOut.exitCode, 0) + assert.match(loggedOut.stdout, /second@example\.com/) + + const remaining = await runSeo(['auth', 'accounts', '--json'], env) + assert.deepEqual( + JSON.parse(remaining.stdout).accounts.map( + (account: { accountEmail: string; active: boolean }) => ({ + email: account.accountEmail, + active: account.active, + }), + ), + [{ email: 'first@example.com', active: true }], + ) + } finally { + await rm(configDir, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) + } +}) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 2cd394b2..17ff91ac 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -5,14 +5,17 @@ import { formatRelativeExpiry, getSeoCliPaths, getTokenStorageStatus, + listGoogleAccounts, loginWithLoopback, + readTokens, refreshAuthToken, SeoError, + setActiveGoogleAccount, setTokenStorageMode, writeOauthClient, } from '@seo/core' import { defineCommand } from 'citty' -import { jsonFlag } from '../args.js' +import { jsonFlag, stringArg } from '../args.js' import { canPrompt, maybeExitCancelled, @@ -100,18 +103,41 @@ export const authCommand = defineCommand({ }, }), logout: defineCommand({ - meta: { name: 'logout', description: 'Delete locally stored tokens' }, - run: async () => { + meta: { name: 'logout', description: 'Delete a local Google login' }, + args: { + account: { + type: 'string', + description: 'Delete the login for this Google account.', + }, + all: { + type: 'boolean', + default: false, + description: 'Delete every saved Google login.', + }, + }, + run: async ({ args }) => { const status = await authStatus() - await deleteTokens() + const account = stringArg(args.account) + if (account && args.all) { + throw new SeoError( + 'INVALID_INPUT', + 'Use either --account or --all, not both.', + ) + } + const targetAccount = args.all + ? undefined + : (account ?? + status.accounts.find((savedAccount) => savedAccount.active) + ?.accountEmail) + await deleteTokens(targetAccount) if (status.activeMode === 'service-account') { process.stdout.write( - 'Deleted local OAuth tokens. Service account credentials remain available through the environment.\n', + `${args.all ? 'Deleted all local Google logins.' : targetAccount ? `Deleted the local login for ${targetAccount}.` : 'No local Google login was found.'} Service account credentials remain available through the environment.\n`, ) return } process.stdout.write( - 'Deleted local OAuth tokens.\nRevoke at https://myaccount.google.com/permissions if you also want Google to forget the grant.\n', + `${args.all ? 'Deleted all local Google logins.' : targetAccount ? `Deleted the local login for ${targetAccount}.` : 'No local Google login was found.'}\nRevoke at https://myaccount.google.com/permissions if you also want Google to forget the grant.\n`, ) }, }), @@ -121,6 +147,10 @@ export const authCommand = defineCommand({ description: 'Show the signed-in Google account', }, args: { + account: { + type: 'string', + description: 'Show this saved Google account.', + }, json: { type: 'boolean', default: false, @@ -147,7 +177,16 @@ export const authCommand = defineCommand({ ]) return } - const tokens = status.tokens + const requestedAccount = stringArg(args.account) + const tokens = requestedAccount + ? await readTokens(requestedAccount) + : status.tokens + if (requestedAccount && !tokens) { + throw new SeoError( + 'AUTH_REQUIRED', + `Google account ${requestedAccount} is not connected.`, + ) + } if (!tokens) { if (jsonFlag(args)) { printJson({ mode: 'oauth', account: null }) @@ -182,6 +221,10 @@ export const authCommand = defineCommand({ description: 'Show local Google auth status', }, args: { + account: { + type: 'string', + description: 'Show status for this saved Google account.', + }, json: { type: 'boolean', default: false, @@ -190,6 +233,20 @@ export const authCommand = defineCommand({ }, run: async ({ args }) => { const status = await authStatus() + const requestedAccount = stringArg(args.account) + const selectedTokens = requestedAccount + ? await readTokens(requestedAccount) + : status.tokens + if ( + requestedAccount && + !selectedTokens && + status.activeMode !== 'service-account' + ) { + throw new SeoError( + 'AUTH_REQUIRED', + `Google account ${requestedAccount} is not connected.`, + ) + } const storage = status.activeMode === 'oauth' ? await getTokenStorageStatus() @@ -198,23 +255,23 @@ export const authCommand = defineCommand({ printJson({ authenticated: status.activeMode === 'oauth' - ? Boolean(status.tokens) + ? Boolean(selectedTokens) : status.serviceAccount.configured, mode: status.activeMode, identity: status.identity, account: status.activeMode === 'oauth' - ? status.tokens?.account_email + ? selectedTokens?.account_email : undefined, scopes: - status.activeMode === 'oauth' ? status.tokens?.scope : undefined, + status.activeMode === 'oauth' ? selectedTokens?.scope : undefined, clientSource: status.activeMode === 'oauth' - ? status.tokens?.client_source + ? selectedTokens?.client_source : undefined, expiresAt: status.activeMode === 'oauth' - ? status.tokens?.expires_at + ? selectedTokens?.expires_at : undefined, sharedConfigured: status.sharedConfigured, byoConfigured: status.byoConfigured, @@ -224,6 +281,7 @@ export const authCommand = defineCommand({ source: status.serviceAccount.source, error: status.serviceAccount.error, }, + accounts: status.accounts, ...(storage ? { tokenStorage: storage } : {}), }) return @@ -242,7 +300,7 @@ export const authCommand = defineCommand({ printKeyValue(rows) return } - if (!status.tokens) { + if (!selectedTokens) { const authMode = status.serviceAccount.error ? status.serviceAccount.error : status.sharedConfigured @@ -257,10 +315,11 @@ export const authCommand = defineCommand({ } const tokenStorage = storage ?? (await getTokenStorageStatus()) printKeyValue([ - ['Account', status.tokens.account_email], - ['Scopes', status.tokens.scope], - ['Client', oauthClientLabel(status.tokens.client_source)], - ['Expires', formatRelativeExpiry(status.tokens.expires_at)], + ['Account', selectedTokens.account_email], + ['Connected accounts', String(status.accounts.length)], + ['Scopes', selectedTokens.scope], + ['Client', oauthClientLabel(selectedTokens.client_source)], + ['Expires', formatRelativeExpiry(selectedTokens.expires_at)], ['Tokens file', getSeoCliPaths().tokensFile], ['Token storage', tokenStorageLabel(tokenStorage)], ...(tokenStorage.reason @@ -332,7 +391,13 @@ export const authCommand = defineCommand({ name: 'refresh', description: 'Refresh the Google OAuth token', }, - run: async () => { + args: { + account: { + type: 'string', + description: 'Refresh this saved Google account.', + }, + }, + run: async ({ args }) => { const status = await authStatus() if (status.activeMode === 'service-account') { process.stdout.write( @@ -340,12 +405,63 @@ export const authCommand = defineCommand({ ) return } - const tokens = await refreshAuthToken() + const tokens = await refreshAuthToken(stringArg(args.account)) process.stdout.write( `Refreshed. New expiry ${new Date(tokens.expires_at).toISOString()}.\n`, ) }, }), + accounts: defineCommand({ + meta: { + name: 'accounts', + description: 'List saved Google accounts', + }, + args: { + json: { + type: 'boolean', + default: false, + description: 'Print machine-readable JSON.', + }, + }, + run: ({ args }) => { + const accounts = listGoogleAccounts() + if (jsonFlag(args)) { + printJson({ accounts }) + return + } + if (accounts.length === 0) { + process.stdout.write('No Google accounts are connected.\n') + return + } + printKeyValue( + accounts.map((account) => [ + account.active ? 'Active account' : 'Account', + account.accountEmail, + ]), + ) + }, + }), + use: defineCommand({ + meta: { + name: 'use', + description: 'Select the default Google account', + }, + args: { + account: { + type: 'positional', + required: true, + description: 'Saved Google account email.', + }, + }, + run: ({ args }) => { + const account = stringArg(args.account) + if (!account) { + throw new SeoError('INVALID_INPUT', 'Google account is required.') + } + setActiveGoogleAccount(account) + process.stdout.write(`Default Google account: ${account}\n`) + }, + }), 'setup-client': defineCommand({ meta: { name: 'setup-client', diff --git a/packages/cli/src/commands/clients/profiles/add.ts b/packages/cli/src/commands/clients/profiles/add.ts index 076ceaaf..449affee 100644 --- a/packages/cli/src/commands/clients/profiles/add.ts +++ b/packages/cli/src/commands/clients/profiles/add.ts @@ -30,6 +30,10 @@ export const clientAddCommand = defineCommand({ type: 'string', description: 'GSC property URL, for example sc-domain:example.com.', }, + 'search-console-account': { + type: 'string', + description: 'Saved Google account email for Search Console.', + }, url: { type: 'string', description: 'Default technical crawl start URL.', @@ -42,6 +46,10 @@ export const clientAddCommand = defineCommand({ type: 'string', description: 'Optional Google Analytics property ID for this project.', }, + 'google-analytics-account': { + type: 'string', + description: 'Saved Google account email for Google Analytics.', + }, 'clicky-site-id': { type: 'string', description: @@ -76,6 +84,8 @@ export const clientAddCommand = defineCommand({ ? (getClient(requestedId) ?? getClient(slugId(requestedId))) : undefined const googleAnalyticsProperty = stringArg(args['google-analytics-property']) + const googleAnalyticsAccount = stringArg(args['google-analytics-account']) + const searchConsoleAccount = stringArg(args['search-console-account']) const clickySiteId = stringArg(args['clicky-site-id']) if (googleAnalyticsProperty && clickySiteId) { throw new SeoError( @@ -83,9 +93,19 @@ export const clientAddCommand = defineCommand({ 'Pass either --google-analytics-property or --clicky-site-id, not both.', ) } + if ( + googleAnalyticsAccount && + !googleAnalyticsProperty && + !existing?.analytics.google?.propertyId + ) { + throw new SeoError( + 'INVALID_INPUT', + '--google-analytics-account needs a Google Analytics property.', + ) + } const siteUrl = await resolveSite({ site: stringArg(args.site) ?? existing?.siteUrl, - options: { json }, + options: { json, account: searchConsoleAccount }, }) const profile = { name: stringArg(args.name), @@ -93,19 +113,35 @@ export const clientAddCommand = defineCommand({ startUrl: stringArg(args.url), watchUrls: args.urls === undefined ? undefined : listArg(args.urls), brandTerms: args.brand === undefined ? undefined : listArg(args.brand), - analytics: googleAnalyticsProperty - ? { - ...existing?.analytics, - selected: 'google' as const, - google: { - propertyId: googleAnalyticsProperty, - }, - } - : clickySiteId + analytics: + googleAnalyticsProperty || googleAnalyticsAccount ? { ...existing?.analytics, - selected: 'clicky' as const, - clicky: { siteId: clickySiteId }, + selected: 'google' as const, + google: { + propertyId: + googleAnalyticsProperty ?? + existing?.analytics.google?.propertyId ?? + '', + }, + } + : clickySiteId + ? { + ...existing?.analytics, + selected: 'clicky' as const, + clicky: { siteId: clickySiteId }, + } + : undefined, + googleAccounts: + searchConsoleAccount || googleAnalyticsAccount + ? { + ...existing?.googleAccounts, + ...(searchConsoleAccount + ? { searchConsole: searchConsoleAccount } + : {}), + ...(googleAnalyticsAccount + ? { googleAnalytics: googleAnalyticsAccount } + : {}), } : undefined, reportDay: numberArg(args['report-day']), diff --git a/packages/cli/src/commands/clients/profiles/output.ts b/packages/cli/src/commands/clients/profiles/output.ts index ae2ed134..a4a96ad9 100644 --- a/packages/cli/src/commands/clients/profiles/output.ts +++ b/packages/cli/src/commands/clients/profiles/output.ts @@ -26,6 +26,10 @@ export function printClientProfile(client: ClientProfile): void { ['ID', client.id], ['Name', client.name], ['GSC property', client.siteUrl], + [ + 'Search Console account', + client.googleAccounts?.searchConsole ?? 'default Google account', + ], ['Crawl URL', client.startUrl ?? 'not set'], ['Watch URLs', client.watchUrls.join(', ') || 'not set'], ['Brand terms', client.brandTerms.join(', ') || 'not set'], @@ -33,6 +37,10 @@ export function printClientProfile(client: ClientProfile): void { 'Google Analytics property', client.analytics.google?.propertyId ?? 'not set', ], + [ + 'Google Analytics account', + client.googleAccounts?.googleAnalytics ?? 'default Google account', + ], ['Clicky site', client.analytics.clicky?.siteId ?? 'not set'], ['Selected analytics', client.analytics.selected ?? 'not set'], ['Report day', client.reportDay ? String(client.reportDay) : 'not set'], diff --git a/packages/cli/src/commands/data.ts b/packages/cli/src/commands/data.ts index a5fb0aba..e7d05eed 100644 --- a/packages/cli/src/commands/data.ts +++ b/packages/cli/src/commands/data.ts @@ -31,6 +31,10 @@ export const gscQueryCommand = defineCommand({ type: 'string', description: 'Saved project id or name.', }, + account: { + type: 'string', + description: 'Saved Google account email.', + }, 'start-date': { type: 'string', description: 'Start date YYYY-MM-DD.' }, 'end-date': { type: 'string', description: 'End date YYYY-MM-DD.' }, dimensions: { @@ -75,11 +79,16 @@ export const gscQueryCommand = defineCommand({ client: projectArg(args), site: stringArg(args.site) ?? stringArg(body.siteUrl), }, - { json, refresh: booleanArg(args.refresh) }, + { + json, + refresh: booleanArg(args.refresh), + account: stringArg(args.account), + }, ) delete body.siteUrl const result = await querySearchAnalytics(site, body as never, { refresh: booleanArg(args.refresh), + accountEmail: stringArg(args.account), }) const limit = numberArg(args.limit) const rows = limit ? result.rows.slice(0, limit) : result.rows @@ -137,6 +146,10 @@ export const urlInspectCommand = defineCommand({ type: 'string', description: 'Saved project id or name.', }, + account: { + type: 'string', + description: 'Saved Google account email.', + }, url: { type: 'string', description: 'URL to inspect.' }, language: { type: 'string', @@ -152,13 +165,14 @@ export const urlInspectCommand = defineCommand({ const json = jsonFlag(args) const siteUrl = await selectedSiteOrThrow( { client: projectArg(args), site: stringArg(args.site) }, - { json }, + { json, account: stringArg(args.account) }, ) const inspectionUrl = stringArg(args.url) if (!inspectionUrl) throw new Error('Pass --url.') const result = await inspectUrl({ siteUrl, inspectionUrl, + accountEmail: stringArg(args.account), languageCode: stringArg(args.language), }) if (json) { diff --git a/packages/cli/src/commands/setup/flow.ts b/packages/cli/src/commands/setup/flow.ts index d1c773e8..9dfcea22 100644 --- a/packages/cli/src/commands/setup/flow.ts +++ b/packages/cli/src/commands/setup/flow.ts @@ -142,11 +142,22 @@ export async function runGuidedSetup( refresh: booleanArg(args.refresh), }, } - const selectedSite = json ? await resolveSite(siteInput) : undefined - const auth = await maybeConnectAuth( + const resolvedNonInteractiveSite = interactive + ? undefined + : await resolveSite(siteInput) + const authSelection = await maybeConnectAuth( selectedProjectSite ? { ...args, site: selectedProjectSite } : args, ) - const site = selectedSite ?? (await resolveSite(siteInput)) + const auth = authSelection.status + const site = + resolvedNonInteractiveSite ?? + (await resolveSite({ + ...siteInput, + options: { + ...siteInput.options, + account: authSelection.accountEmail, + }, + })) const defaultName = suggestedClientName(site) if (projectTarget.mode === 'skip') { @@ -216,6 +227,7 @@ export async function runGuidedSetup( : listArg(args.urls) const analyticsSelection = await chooseAnalyticsForSetup({ googleProperty: stringArg(args['google-analytics-property']), + googleAccount: stringArg(args['google-analytics-account']), clickySiteId: stringArg(args['clicky-site-id']), current: analyticsConnection(existingProject), site, @@ -270,6 +282,15 @@ export async function runGuidedSetup( watchUrls, brandTerms, analytics, + googleAccounts: { + ...existingProject?.googleAccounts, + ...(authSelection.accountEmail + ? { searchConsole: authSelection.accountEmail } + : {}), + ...(googleAnalytics?.accountEmail + ? { googleAnalytics: googleAnalytics.accountEmail } + : {}), + }, reportDay, technicalWeekday, isDefault, @@ -305,6 +326,10 @@ export async function runGuidedSetup( printKeyValue([ ['Project profile', `${client.name} (${client.id})`], ['GSC property', client.siteUrl], + [ + 'Search Console account', + client.googleAccounts?.searchConsole ?? 'default Google account', + ], ['Crawl URL', client.startUrl ?? 'not set'], ['Watch URLs', String(client.watchUrls.length)], ['Brand terms', client.brandTerms.join(', ') || 'not set'], @@ -322,6 +347,14 @@ export async function runGuidedSetup( ], ] : []), + ...(client.googleAccounts?.googleAnalytics + ? [ + [ + 'Google Analytics account', + client.googleAccounts.googleAnalytics, + ] as [string, string], + ] + : []), ['Auth', auth], ['MCP', mcpInstallLabel(mcp)], ['SEO skill', skillInstallLabel(skill)], diff --git a/packages/cli/src/commands/setup/index.ts b/packages/cli/src/commands/setup/index.ts index 40f24e48..c5585a97 100644 --- a/packages/cli/src/commands/setup/index.ts +++ b/packages/cli/src/commands/setup/index.ts @@ -14,6 +14,10 @@ export const setupCommand = defineCommand({ type: 'string', description: 'GSC property URL, for example sc-domain:example.com.', }, + 'search-console-account': { + type: 'string', + description: 'Saved Google account email for Search Console.', + }, url: { type: 'string', description: 'Default technical crawl start URL.' }, urls: { type: 'string', @@ -23,6 +27,10 @@ export const setupCommand = defineCommand({ type: 'string', description: 'Optional Google Analytics property ID.', }, + 'google-analytics-account': { + type: 'string', + description: 'Saved Google account email for Google Analytics.', + }, 'clicky-site-id': { type: 'string', description: diff --git a/packages/cli/src/commands/setup/prompts.ts b/packages/cli/src/commands/setup/prompts.ts index 12cd8fd5..ca14a2bd 100644 --- a/packages/cli/src/commands/setup/prompts.ts +++ b/packages/cli/src/commands/setup/prompts.ts @@ -16,6 +16,7 @@ import { ga4PropertyIdFromName, listGa4AccountSummaries, listGa4DataStreams, + listGoogleAccounts, loadProviderExtensions, loginWithLoopback, matchGa4WebStreams, @@ -23,6 +24,7 @@ import { readClickySiteKey, readProviderExtensionCredentials, SeoError, + setActiveGoogleAccount, verifyAnalyticsProvider, writeOauthClient, writeProviderExtensionCredentials, @@ -37,6 +39,10 @@ export type SetupAuthStatus = | 'already-connected' | 'service-account' | 'skipped' +export type SetupAuthResult = { + status: SetupAuthStatus + accountEmail?: string +} export type SetupMcpInstall = { client: string path: string @@ -45,6 +51,7 @@ export type SetupMcpInstall = { } export type SetupGoogleAnalyticsSelection = { propertyId: string + accountEmail?: string selection: 'explicit' | 'matched' | 'manual' reason: string } @@ -71,6 +78,10 @@ type GoogleAnalyticsSetupChoice = GoogleAnalyticsPropertyChoice & { } type AuthSetupChoice = 'login' | 'setup' | 'skip' +type SavedGoogleAccountChoice = + | { type: 'account'; accountEmail: string } + | { type: 'connect' } + | { type: 'skip' } type AnalyticsSetupChoice = | 'keep' | 'google' @@ -264,6 +275,7 @@ export function authSetupOptions(input: { async function findGoogleAnalyticsWebStreamCandidates( properties: GoogleAnalyticsPropertyChoice[], + accountEmail?: string, ): Promise<{ candidates: Ga4WebStreamCandidate[]; complete: boolean }> { const candidates: Ga4WebStreamCandidate[] = [] let complete = true @@ -276,7 +288,10 @@ async function findGoogleAnalyticsWebStreamCandidates( if (!property) continue try { - const streams = await listGa4DataStreams(property.property) + const streams = await listGa4DataStreams( + property.property, + accountEmail, + ) candidates.push( ...streams .filter((stream) => stream.webStreamData) @@ -301,18 +316,89 @@ async function findGoogleAnalyticsWebStreamCandidates( export async function maybeConnectAuth( args: Record, -): Promise { +): Promise { const status = await authStatus() - if (status.activeMode === 'service-account') return 'service-account' - if (status.tokens) return 'already-connected' - if (args['skip-auth']) return 'skipped' + if (status.activeMode === 'service-account') { + return { status: 'service-account', accountEmail: status.identity } + } + const requestedAccount = + typeof args['search-console-account'] === 'string' + ? args['search-console-account'] + : undefined + const connectedAccounts = listGoogleAccounts() + if (requestedAccount) { + const selected = connectedAccounts.find( + (account) => + account.accountEmail.toLowerCase() === requestedAccount.toLowerCase(), + ) + if (!selected) { + throw new SeoError( + 'AUTH_REQUIRED', + `Google account ${requestedAccount} is not connected. Run \`seo auth login\` first.`, + ) + } + return { status: 'already-connected', accountEmail: selected.accountEmail } + } + if (args['skip-auth']) return { status: 'skipped' } if (!canPrompt({ json: args.json === true })) { + if (status.tokens) { + return { + status: 'already-connected', + accountEmail: status.tokens.account_email, + } + } throw new SeoError( 'AUTH_REQUIRED', 'Not logged in. Run `seo auth login`, or pass --skip-auth to save a project profile without connecting Google.', ) } + if (connectedAccounts.length > 0) { + const choice = maybeExitCancelled( + await select({ + message: 'Google account for Search Console', + options: [ + ...connectedAccounts.map((account) => ({ + value: { + type: 'account' as const, + accountEmail: account.accountEmail, + }, + label: account.accountEmail, + hint: account.active ? 'Current default' : undefined, + })), + { + value: { type: 'connect' as const }, + label: 'Connect another Google account', + }, + ...(typeof args.site === 'string' + ? [ + { + value: { type: 'skip' as const }, + label: 'Skip for now', + }, + ] + : []), + ], + }), + ) + if (choice.type === 'account') { + setActiveGoogleAccount(choice.accountEmail) + return { + status: 'already-connected', + accountEmail: choice.accountEmail, + } + } + if (choice.type === 'skip') return { status: 'skipped' } + if (status.sharedConfigured || status.byoConfigured) { + const tokens = await loginWithLoopback() + note( + `Connected as ${tokens.account_email}. seo has read-only access and cannot change your site.`, + 'Google connected', + ) + return { status: 'connected', accountEmail: tokens.account_email } + } + } + const choice = maybeExitCancelled( await select({ message: 'Connect Google now?', @@ -324,7 +410,7 @@ export async function maybeConnectAuth( }), ) - if (choice === 'skip') return 'skipped' + if (choice === 'skip') return { status: 'skipped' } if (choice === 'setup') { const clientId = maybeExitCancelled( await text({ @@ -346,17 +432,19 @@ export async function maybeConnectAuth( `Connected as ${tokens.account_email}. seo has read-only access and cannot change your site.`, 'Google connected', ) - return 'connected' + return { status: 'connected', accountEmail: tokens.account_email } } export async function chooseGoogleAnalyticsProperty(input: { property?: string + accountEmail?: string site: string interactive?: boolean }): Promise { if (input.property) { return { propertyId: input.property, + accountEmail: input.accountEmail, selection: 'explicit', reason: 'Set with --google-analytics-property.', } @@ -364,7 +452,9 @@ export async function chooseGoogleAnalyticsProperty(input: { const interactive = input.interactive ?? canPrompt() if (!interactive) return undefined - const summaries = await listGa4AccountSummaries().catch(() => []) + const summaries = await listGa4AccountSummaries(input.accountEmail).catch( + () => [], + ) const properties: GoogleAnalyticsPropertyChoice[] = summaries.flatMap( (account) => account.propertySummaries.map((property) => ({ @@ -376,7 +466,7 @@ export async function chooseGoogleAnalyticsProperty(input: { if (!properties.length) return undefined const { candidates, complete: streamsComplete } = - await findGoogleAnalyticsWebStreamCandidates(properties) + await findGoogleAnalyticsWebStreamCandidates(properties, input.accountEmail) const matches = streamsComplete ? matchGa4WebStreams(input.site, candidates) : [] @@ -391,6 +481,7 @@ export async function chooseGoogleAnalyticsProperty(input: { if (!match) return undefined return { propertyId: match.property, + accountEmail: input.accountEmail, selection: 'matched', reason: ga4MatchReason(match, input.site), } @@ -428,6 +519,7 @@ export async function chooseGoogleAnalyticsProperty(input: { return { propertyId: choice.property, + accountEmail: input.accountEmail, selection: 'manual', reason: choice.match ? ga4MatchReason(choice.match, input.site) @@ -439,6 +531,7 @@ export async function chooseGoogleAnalyticsProperty(input: { export async function chooseAnalyticsForSetup(input: { googleProperty?: string + googleAccount?: string clickySiteId?: string current?: AnalyticsConnection site: string @@ -467,8 +560,13 @@ export async function chooseAnalyticsForSetup(input: { ) } if (input.googleProperty) { + const accountEmail = await chooseGoogleAnalyticsAccount({ + requested: input.googleAccount, + interactive, + }) const google = await chooseGoogleAnalyticsProperty({ property: input.googleProperty, + accountEmail, site: input.site, interactive, }) @@ -501,7 +599,9 @@ export async function chooseAnalyticsForSetup(input: { } return connectAnalyticsExtension(provider) } + const accountEmail = await chooseGoogleAnalyticsAccount({ interactive }) const google = await chooseGoogleAnalyticsProperty({ + accountEmail, site: input.site, interactive, }) @@ -514,6 +614,61 @@ export async function chooseAnalyticsForSetup(input: { return google ? { provider: 'google', google } : undefined } +async function chooseGoogleAnalyticsAccount(input: { + requested?: string + interactive: boolean +}): Promise { + const status = await authStatus() + if (status.activeMode === 'service-account') return status.identity + const accounts = listGoogleAccounts() + if (input.requested) { + const selected = accounts.find( + (account) => + account.accountEmail.toLowerCase() === input.requested?.toLowerCase(), + ) + if (!selected) { + throw new SeoError( + 'AUTH_REQUIRED', + `Google account ${input.requested} is not connected. Run \`seo auth login\` first.`, + ) + } + return selected.accountEmail + } + if (!input.interactive) { + return accounts.find((account) => account.active)?.accountEmail + } + if (accounts.length === 0) { + const tokens = await loginWithLoopback() + return tokens.account_email + } + const choice = maybeExitCancelled( + await select({ + message: 'Google account for Google Analytics', + options: [ + ...accounts.map((account) => ({ + value: { + type: 'account' as const, + accountEmail: account.accountEmail, + }, + label: account.accountEmail, + hint: account.active ? 'Current default' : undefined, + })), + { + value: { type: 'connect' as const }, + label: 'Connect another Google account', + }, + ], + }), + ) + if (choice.type === 'account') return choice.accountEmail + const tokens = await loginWithLoopback() + note( + `Connected as ${tokens.account_email}. seo has read-only access and cannot change your site.`, + 'Google connected', + ) + return tokens.account_email +} + export async function maybeInstallSkill( args: Record, ): Promise { diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index b3fed5c0..c10dc1f4 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -10,7 +10,7 @@ export async function selectedSiteOrThrow( project?: string site?: string }, - options: { json?: boolean; refresh?: boolean } = {}, + options: { json?: boolean; refresh?: boolean; account?: string } = {}, ): Promise { return ( await resolveClientSelection({ diff --git a/packages/cli/src/commands/system.ts b/packages/cli/src/commands/system.ts index ff401651..4daf21e4 100644 --- a/packages/cli/src/commands/system.ts +++ b/packages/cli/src/commands/system.ts @@ -95,9 +95,16 @@ export const sitesCommand = defineCommand({ args: { json: { type: 'boolean', default: false }, refresh: { type: 'boolean', default: false }, + account: { + type: 'string', + description: 'Saved Google account email.', + }, }, run: async ({ args }) => { - const sites = await listSites(booleanArg(args.refresh)) + const sites = await listSites( + booleanArg(args.refresh), + stringArg(args.account), + ) if (jsonFlag(args)) { printJson({ sites }) return diff --git a/packages/cli/src/help.test.ts b/packages/cli/src/help.test.ts index 2895ec43..9a51b979 100644 --- a/packages/cli/src/help.test.ts +++ b/packages/cli/src/help.test.ts @@ -307,8 +307,11 @@ test('auth status and interactive-only setup stay structured in JSON mode', asyn SEO_CACHE_DIR: cacheDir, }) assert.equal(status.exitCode, 0) - const { sharedConfigured, ...statusJson } = JSON.parse(status.stdout) + const { sharedConfigured, accounts, ...statusJson } = JSON.parse( + status.stdout, + ) assert.equal(typeof sharedConfigured, 'boolean') + assert.deepEqual(accounts, []) assert.deepEqual(statusJson, { authenticated: false, mode: 'none', @@ -405,8 +408,12 @@ test('auth status reports a service account identity without printing the key', }), }) assert.equal(status.exitCode, 0) - const { sharedConfigured, ...statusJson } = JSON.parse(status.stdout) + const { sharedConfigured, accounts, ...statusJson } = JSON.parse( + status.stdout, + ) assert.equal(typeof sharedConfigured, 'boolean') + assert.equal(accounts.length, 1) + assert.equal(accounts[0].accountEmail, 'stored-account@example.com') assert.deepEqual(statusJson, { authenticated: true, mode: 'service-account', diff --git a/packages/cli/src/onboarding.integration.test.ts b/packages/cli/src/onboarding.integration.test.ts index 26b8786b..81f4786b 100644 --- a/packages/cli/src/onboarding.integration.test.ts +++ b/packages/cli/src/onboarding.integration.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -221,6 +221,78 @@ test('a new project never inherits the saved default Search Console property', a } }) +test('start saves separate Google accounts for Search Console and Analytics', async () => { + const configDir = await mkdtemp(join(tmpdir(), 'seo-start-accounts-config-')) + const cacheDir = await mkdtemp(join(tmpdir(), 'seo-start-accounts-cache-')) + const env = { SEO_CONFIG_DIR: configDir, SEO_CACHE_DIR: cacheDir } + const token = (accountEmail: string) => ({ + provider: 'google', + account_email: accountEmail, + scope: [ + 'openid', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/webmasters.readonly', + 'https://www.googleapis.com/auth/analytics.readonly', + ].join(' '), + token_type: 'Bearer', + access_token: `${accountEmail}-access`, + refresh_token: `${accountEmail}-refresh`, + expires_at: Date.now() + 3_600_000, + obtained_at: Date.now(), + client_source: 'shared', + }) + + try { + await writeFile( + join(configDir, 'config.json'), + JSON.stringify({ security: { useKeychain: false } }), + ) + await writeFile( + join(configDir, 'tokens.json'), + JSON.stringify({ + version: 2, + active_account: 'search@example.com', + accounts: [token('search@example.com'), token('analytics@example.com')], + }), + ) + + const result = await runSeoResult( + [ + 'start', + '--id', + 'example', + '--name', + 'Example', + '--site', + 'sc-domain:example.com', + '--search-console-account', + 'search@example.com', + '--google-analytics-property', + '123', + '--google-analytics-account', + 'analytics@example.com', + '--skip-mcp', + '--skip-skill', + '--json', + ], + env, + ) + + assert.equal(result.exitCode, 0, result.stderr) + const client = JSON.parse(result.stdout).client + assert.deepEqual(client.googleAccounts, { + searchConsole: 'search@example.com', + googleAnalytics: 'analytics@example.com', + }) + assert.deepEqual(client.analytics.google, { + propertyId: '123', + }) + } finally { + await rm(configDir, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) + } +}) + test('projects add attaches Clicky without clearing existing project fields', async () => { const configDir = await mkdtemp(join(tmpdir(), 'seo-project-update-config-')) const cacheDir = await mkdtemp(join(tmpdir(), 'seo-project-update-cache-')) diff --git a/packages/cli/src/selection.ts b/packages/cli/src/selection.ts index 4e2dd4cb..2fc3043f 100644 --- a/packages/cli/src/selection.ts +++ b/packages/cli/src/selection.ts @@ -8,6 +8,7 @@ import { listSites, readConfig, SeoError, + selectGoogleAccounts, } from '@seo/core' import { canPrompt, maybeExitCancelled } from './utils.js' @@ -15,6 +16,7 @@ type ResolveOptions = { allowDefault?: boolean json?: boolean refresh?: boolean + account?: string } type SiteChoice = { @@ -33,6 +35,10 @@ export type ClientSelection = { site: string } +function selectClientGoogleAccounts(client: ClientProfile | undefined): void { + selectGoogleAccounts(client?.googleAccounts) +} + function includesQuery(values: string[], query: string): boolean { const normalized = query.trim().toLowerCase() if (!normalized) return true @@ -97,6 +103,11 @@ export async function resolveSite(input: { config.defaultSite && defaultSiteKnown ) { + selectClientGoogleAccounts( + config.clients.find( + (client) => client.isDefault && client.siteUrl === config.defaultSite, + ), + ) return config.defaultSite } @@ -109,7 +120,7 @@ export async function resolveSite(input: { ) } - const sites = await listSites(input.options?.refresh) + const sites = await listSites(input.options?.refresh, input.options?.account) if (!sites.length) { throw new SeoError( 'PROPERTY_NOT_FOUND', @@ -151,11 +162,13 @@ export async function resolveClientSelection(input: { if (!client) { throw new SeoError('INVALID_INPUT', `Project not found: ${project}`) } + selectClientGoogleAccounts(client) return { client, site: client.siteUrl } } const defaultClient = getClient() if (!input.site && defaultClient) { + selectClientGoogleAccounts(defaultClient) return { client: defaultClient, site: defaultClient.siteUrl } } @@ -181,17 +194,21 @@ export async function resolveClient(input: { if (!client) { throw new SeoError('INVALID_INPUT', `Project not found: ${project}`) } + selectClientGoogleAccounts(client) return client } const clients = listClients() if (!clients.length) return undefined const defaultClient = getClient() - if (defaultClient) return defaultClient + if (defaultClient) { + selectClientGoogleAccounts(defaultClient) + return defaultClient + } if (input.options?.json || !canPrompt()) return undefined - return chooseFromSearch({ + const selected = await chooseFromSearch({ message: 'Choose a project', searchMessage: 'Search projects', emptyMessage: 'No projects matched that search.', @@ -200,6 +217,8 @@ export async function resolveClient(input: { hint: (client) => client.siteUrl, searchValues: (client) => [client.id, client.name, client.siteUrl], }) + selectClientGoogleAccounts(selected) + return selected } export async function resolveGoogleAnalyticsProperty(input: { @@ -220,7 +239,7 @@ export async function resolveGoogleAnalyticsProperty(input: { ) } - const accountSummaries = await listGa4AccountSummaries() + const accountSummaries = await listGa4AccountSummaries(input.options?.account) const choices = accountSummaries.flatMap((account) => account.propertySummaries.map((property) => ({ property: ga4PropertyIdFromName(property.property), diff --git a/packages/core/src/clients.test.ts b/packages/core/src/clients.test.ts index 292c7dde..c410aeed 100644 --- a/packages/core/src/clients.test.ts +++ b/packages/core/src/clients.test.ts @@ -107,6 +107,55 @@ test('Clicky can be selected as the project analytics connection', () => { }) }) +test('a project keeps separate Google accounts for each data source', () => { + const client = saveClient({ + id: 'separate-google-accounts', + name: 'Separate Google accounts', + siteUrl: 'sc-domain:example.com', + googleAccounts: { + searchConsole: 'search@example.com', + googleAnalytics: 'analytics@example.com', + }, + analytics: { + selected: 'google', + google: { propertyId: '123' }, + }, + }) + + assert.equal(client.googleAccounts?.searchConsole, 'search@example.com') + assert.deepEqual(analyticsConnection(client), { + provider: 'google', + propertyId: '123', + accountEmail: 'analytics@example.com', + }) +}) + +test('removing Google Analytics also removes its saved account', () => { + saveClient({ + id: 'remove-google-analytics', + name: 'Remove Google Analytics', + siteUrl: 'sc-domain:example.com', + googleAccounts: { + searchConsole: 'search@example.com', + googleAnalytics: 'analytics@example.com', + }, + analytics: { + selected: 'google', + google: { propertyId: '123' }, + }, + }) + + const client = removeClientAnalyticsConnection( + 'remove-google-analytics', + 'google', + ) + + assert.deepEqual(client.googleAccounts, { + searchConsole: 'search@example.com', + }) + assert.equal(client.analytics.google, undefined) +}) + test('an installed provider can be selected without changing the profile schema again', () => { const client = saveClient({ id: 'fathom-example', diff --git a/packages/core/src/clients.ts b/packages/core/src/clients.ts index 876d505e..f7a47376 100644 --- a/packages/core/src/clients.ts +++ b/packages/core/src/clients.ts @@ -10,6 +10,7 @@ export type ClientProfileInput = { watchUrls?: string[] brandTerms?: string[] analytics?: ClientProfile['analytics'] + googleAccounts?: ClientProfile['googleAccounts'] searchEngines?: ClientProfile['searchEngines'] reportDay?: number technicalWeekday?: number @@ -27,7 +28,7 @@ export function googleAnalyticsPropertyId( } export function analyticsConnection( - client: Pick | undefined, + client: Pick | undefined, ): AnalyticsConnection | undefined { const analytics = client?.analytics if (!analytics) return undefined @@ -44,12 +45,21 @@ export function analyticsConnection( : undefined } if (analytics.selected === 'google') { - return analytics.google - ? { provider: 'google', propertyId: analytics.google.propertyId } - : undefined + if (!analytics.google) return undefined + const accountEmail = client?.googleAccounts?.googleAnalytics + return { + provider: 'google', + propertyId: analytics.google.propertyId, + ...(accountEmail ? { accountEmail } : {}), + } } if (analytics.google) { - return { provider: 'google', propertyId: analytics.google.propertyId } + const accountEmail = client?.googleAccounts?.googleAnalytics + return { + provider: 'google', + propertyId: analytics.google.propertyId, + ...(accountEmail ? { accountEmail } : {}), + } } if (analytics.clicky) { return { provider: 'clicky', siteId: analytics.clicky.siteId } @@ -125,6 +135,7 @@ export function saveClient(input: ClientProfileInput): ClientProfile { }), ), analytics: input.analytics ?? existing?.analytics ?? {}, + googleAccounts: input.googleAccounts ?? existing?.googleAccounts, searchEngines: input.searchEngines ?? existing?.searchEngines, reportDay: input.reportDay ?? existing?.reportDay, technicalWeekday: input.technicalWeekday ?? existing?.technicalWeekday, @@ -184,6 +195,14 @@ export function setClientAnalyticsConnection( selected: 'google', google: { propertyId: connection.propertyId }, }, + ...(connection.provider === 'google' && connection.accountEmail + ? { + googleAccounts: { + ...client.googleAccounts, + googleAnalytics: connection.accountEmail, + }, + } + : {}), }) } @@ -215,7 +234,10 @@ export function removeClientAnalyticsConnection( ? (`extension:${Object.keys(analytics.extensions ?? {}).sort()[0]}` as const) : undefined } - return updateClient(client.id, { analytics }) + if (provider !== 'google') return updateClient(client.id, { analytics }) + const googleAccounts = { ...client.googleAccounts } + delete googleAccounts.googleAnalytics + return updateClient(client.id, { analytics, googleAccounts }) } export function setClientBingSite( diff --git a/packages/core/src/ga4/client.ts b/packages/core/src/ga4/client.ts index 5c5db009..fc44b072 100644 --- a/packages/core/src/ga4/client.ts +++ b/packages/core/src/ga4/client.ts @@ -1,5 +1,6 @@ import { fetch, type RequestInit } from 'undici' import { readBoundedResponseText } from '../fetch/http-client.js' +import { googleAnalyticsAccountForProperty } from '../gsc/auth/account-selection.js' import { createGoogleAccessTokenClient, type GoogleAccessTokenClient, @@ -92,10 +93,13 @@ async function authedFetch( export async function runGa4Report( propertyId: string, body: Ga4ReportRequest, - opts: { refresh?: boolean } = {}, + opts: { refresh?: boolean; accountEmail?: string } = {}, ): Promise { + const { client, identity } = await createGoogleAccessTokenClient( + opts.accountEmail ?? googleAnalyticsAccountForProperty(propertyId), + ) const db = getDb() - const queryHash = hashKey([propertyId, body]) + const queryHash = hashKey([propertyId, identity, body]) const cacheable = ga4RequestCanUseCache(body) const cached = cacheable ? (db @@ -111,7 +115,6 @@ export async function runGa4Report( return JSON.parse(cached.response_json) as Ga4RunReportResult } - const { client } = await createGoogleAccessTokenClient() const response = await authedFetch( client, `https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`, @@ -154,8 +157,10 @@ export async function runGa4Report( return result } -export async function listGa4AccountSummaries(): Promise { - const { client } = await createGoogleAccessTokenClient() +export async function listGa4AccountSummaries( + accountEmail?: string, +): Promise { + const { client } = await createGoogleAccessTokenClient(accountEmail) return collectGa4AccountSummaries(async (pageToken) => { const url = new URL( 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries', @@ -190,8 +195,11 @@ export async function listGa4AccountSummaries(): Promise { export async function listGa4DataStreams( propertyId: string, + accountEmail?: string, ): Promise { - const { client } = await createGoogleAccessTokenClient() + const { client } = await createGoogleAccessTokenClient( + accountEmail ?? googleAnalyticsAccountForProperty(propertyId), + ) const streams: Ga4DataStream[] = [] let pageToken: string | undefined let pageCount = 0 diff --git a/packages/core/src/gsc/auth.ts b/packages/core/src/gsc/auth.ts index a2cc2cf9..3ef5a6c7 100644 --- a/packages/core/src/gsc/auth.ts +++ b/packages/core/src/gsc/auth.ts @@ -1,3 +1,4 @@ +export * from './auth/account-context.js' export * from './auth/authorized-client.js' export * from './auth/client-config.js' export * from './auth/loopback.js' diff --git a/packages/core/src/gsc/auth/account-context.ts b/packages/core/src/gsc/auth/account-context.ts new file mode 100644 index 00000000..111b7491 --- /dev/null +++ b/packages/core/src/gsc/auth/account-context.ts @@ -0,0 +1,20 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +export type GoogleAccountContext = { + searchConsole?: string + googleAnalytics?: string +} + +const googleAccountContext = new AsyncLocalStorage() + +export function selectGoogleAccounts( + accounts: GoogleAccountContext | undefined, +): void { + googleAccountContext.enterWith(accounts ?? {}) +} + +export function selectedGoogleAccount( + source: keyof GoogleAccountContext, +): string | undefined { + return googleAccountContext.getStore()?.[source] +} diff --git a/packages/core/src/gsc/auth/account-selection.test.ts b/packages/core/src/gsc/auth/account-selection.test.ts new file mode 100644 index 00000000..c43c6230 --- /dev/null +++ b/packages/core/src/gsc/auth/account-selection.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, test } from 'node:test' +import { saveClient } from '../../clients.js' +import { selectGoogleAccounts } from './account-context.js' +import { + googleAnalyticsAccountForProperty, + searchConsoleAccountForSite, +} from './account-selection.js' + +let configDir: string +let previousConfigDir: string | undefined + +beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'seo-google-account-selection-')) + previousConfigDir = process.env.SEO_CONFIG_DIR + process.env.SEO_CONFIG_DIR = configDir +}) + +afterEach(() => { + selectGoogleAccounts(undefined) + if (previousConfigDir === undefined) delete process.env.SEO_CONFIG_DIR + else process.env.SEO_CONFIG_DIR = previousConfigDir + rmSync(configDir, { recursive: true, force: true }) +}) + +test('resolves each project data source to its saved Google account', () => { + saveClient({ + id: 'example', + name: 'Example', + siteUrl: 'sc-domain:example.com', + googleAccounts: { + searchConsole: 'search@example.com', + googleAnalytics: 'analytics@example.com', + }, + analytics: { + google: { propertyId: '123' }, + }, + }) + + assert.equal( + searchConsoleAccountForSite('sc-domain:example.com'), + 'search@example.com', + ) + assert.equal( + googleAnalyticsAccountForProperty('123'), + 'analytics@example.com', + ) +}) + +test('rejects ambiguous resource matching across project accounts', () => { + for (const account of ['first@example.com', 'second@example.com']) { + saveClient({ + id: account.split('@')[0], + name: account, + siteUrl: 'sc-domain:example.com', + googleAccounts: { searchConsole: account }, + }) + } + + assert.throws( + () => searchConsoleAccountForSite('sc-domain:example.com'), + /different Google accounts/, + ) +}) + +test('a selected project account resolves an otherwise ambiguous resource', () => { + for (const account of ['first@example.com', 'second@example.com']) { + saveClient({ + id: account.split('@')[0], + name: account, + siteUrl: 'sc-domain:example.com', + googleAccounts: { searchConsole: account }, + }) + } + selectGoogleAccounts({ searchConsole: 'second@example.com' }) + + assert.equal( + searchConsoleAccountForSite('sc-domain:example.com'), + 'second@example.com', + ) +}) diff --git a/packages/core/src/gsc/auth/account-selection.ts b/packages/core/src/gsc/auth/account-selection.ts new file mode 100644 index 00000000..a88ea3c7 --- /dev/null +++ b/packages/core/src/gsc/auth/account-selection.ts @@ -0,0 +1,47 @@ +import { SeoError } from '../../errors.js' +import { readConfig } from '../../storage/config.js' +import { selectedGoogleAccount } from './account-context.js' + +function oneAccount( + values: Array, + resource: string, +): string | undefined { + const accounts = [ + ...new Map( + values + .filter((value): value is string => Boolean(value)) + .map((value) => [value.toLowerCase(), value]), + ).values(), + ] + if (accounts.length <= 1) return accounts[0] + throw new SeoError( + 'INVALID_INPUT', + `Saved projects use different Google accounts for ${resource}. Select a project-specific account instead of relying on resource matching.`, + ) +} + +export function searchConsoleAccountForSite(site: string): string | undefined { + const selected = selectedGoogleAccount('searchConsole') + if (selected) return selected + return oneAccount( + readConfig() + .clients.filter((client) => client.siteUrl === site) + .map((client) => client.googleAccounts?.searchConsole), + site, + ) +} + +export function googleAnalyticsAccountForProperty( + propertyId: string, +): string | undefined { + const selected = selectedGoogleAccount('googleAnalytics') + if (selected) return selected + return oneAccount( + readConfig() + .clients.filter( + (client) => client.analytics.google?.propertyId === propertyId, + ) + .map((client) => client.googleAccounts?.googleAnalytics), + `Google Analytics property ${propertyId}`, + ) +} diff --git a/packages/core/src/gsc/auth/authorized-client.test.ts b/packages/core/src/gsc/auth/authorized-client.test.ts index fc114ae7..3d48a412 100644 --- a/packages/core/src/gsc/auth/authorized-client.test.ts +++ b/packages/core/src/gsc/auth/authorized-client.test.ts @@ -16,9 +16,13 @@ process.env.SEO_LOCK_FAST = '1' const { SeoError } = await import('../../errors.js') const { setKeyringForTests } = await import('../../storage/keyring.js') -const { readTokens, writeConfig, writeOauthClient, writeTokens } = await import( - '../../storage/config.js' -) +const { + listGoogleAccounts, + readTokens, + writeConfig, + writeOauthClient, + writeTokens, +} = await import('../../storage/config.js') const { createAuthorizedClient, refreshAuthToken } = await import( './authorized-client.js' ) @@ -155,6 +159,54 @@ test('refresh updates stored access data without changing its client source', { assert.deepEqual(await readTokens(), refreshed) }) +test('refresh updates one account without changing the active account', { + timeout: 10_000, +}, async () => { + await writeTokens( + storedTokens({ + account_email: 'analytics@example.com', + refresh_token: 'analytics-refresh-token', + }), + ) + await writeTokens( + storedTokens({ + account_email: 'search@example.com', + access_token: 'search-access-token', + refresh_token: 'search-refresh-token', + expires_at: Date.now() + 3_600_000, + }), + ) + globalThis.fetch = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + const body = new URLSearchParams(String(init?.body)) + assert.equal(body.get('refresh_token'), 'analytics-refresh-token') + return new Response( + JSON.stringify({ + access_token: 'next-analytics-access-token', + expires_in: 3_600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + }) as typeof fetch + + await refreshAuthToken('analytics@example.com') + + assert.equal( + (await readTokens('analytics@example.com'))?.access_token, + 'next-analytics-access-token', + ) + assert.equal( + (await readTokens('search@example.com'))?.access_token, + 'search-access-token', + ) + assert.equal( + listGoogleAccounts().find((account) => account.active)?.accountEmail, + 'search@example.com', + ) +}) + test('concurrent authorized clients refresh an expiring token once under lock', { timeout: 10_000, }, async () => { diff --git a/packages/core/src/gsc/auth/authorized-client.ts b/packages/core/src/gsc/auth/authorized-client.ts index d34dde9f..64c50a9d 100644 --- a/packages/core/src/gsc/auth/authorized-client.ts +++ b/packages/core/src/gsc/auth/authorized-client.ts @@ -1,6 +1,11 @@ import { SeoError } from '../../errors.js' import { getSeoCliPaths } from '../../paths.js' -import { deleteTokens, readTokens, writeTokens } from '../../storage/config.js' +import { + deleteTokens, + listGoogleAccounts, + readTokens, + writeTokens, +} from '../../storage/config.js' import { withFileLock } from '../../storage/lock.js' import type { StoredTokens } from '../../types.js' import { @@ -27,10 +32,12 @@ import { const REFRESH_BUFFER_MS = 60_000 -function authRequired(): SeoError { +function authRequired(accountEmail?: string): SeoError { return new SeoError( 'AUTH_REQUIRED', - 'Not logged in. Run `seo auth login` first.', + accountEmail + ? `Google account ${accountEmail} is not connected. Run \`seo auth login\` first.` + : 'Not logged in. Run `seo auth login` first.', ) } @@ -78,10 +85,11 @@ function refreshRejected(error: unknown): boolean { async function refreshStoredToken(input: { onlyIfExpiring: boolean + accountEmail?: string }): Promise { return withFileLock(getSeoCliPaths().tokensFile, async () => { - const current = await readTokens() - if (!current) throw authRequired() + const current = await readTokens(input.accountEmail) + if (!current) throw authRequired(input.accountEmail) const clientConfig = requireClientConfig(current) const now = Date.now() @@ -89,7 +97,7 @@ async function refreshStoredToken(input: { return current } if (!current.refresh_token) { - await deleteTokens() + await deleteTokens(current.account_email) throw authExpired() } @@ -104,11 +112,11 @@ async function refreshStoredToken(input: { refresh_token: refreshed.refreshToken ?? current.refresh_token, expires_at: now + refreshed.expiresIn * 1_000, } - await writeTokens(next) + await writeTokens(next, { makeActive: false }) return next } catch (error) { if (refreshRejected(error)) { - await deleteTokens() + await deleteTokens(current.account_email) throw authExpired() } throw error @@ -123,6 +131,7 @@ class LocalGoogleAccessTokenClient implements GoogleAccessTokenClient { if (needsRefresh(this.tokens, Date.now())) { this.tokens = await refreshStoredToken({ onlyIfExpiring: true, + accountEmail: this.tokens.account_email, }) } if (!this.tokens.access_token) throw authExpired() @@ -130,18 +139,19 @@ class LocalGoogleAccessTokenClient implements GoogleAccessTokenClient { } } -export async function createAuthorizedClient(): Promise<{ +export async function createAuthorizedClient(accountEmail?: string): Promise<{ client: GoogleAccessTokenClient tokens: StoredTokens }> { - let tokens = await readTokens() - if (!tokens) throw authRequired() + let tokens = await readTokens(accountEmail) + if (!tokens) throw authRequired(accountEmail) requireClientConfig(tokens) requireReadonlyScopes(tokens) if (needsRefresh(tokens, Date.now())) { tokens = await refreshStoredToken({ onlyIfExpiring: true, + accountEmail: tokens.account_email, }) } @@ -151,8 +161,10 @@ export async function createAuthorizedClient(): Promise<{ } } -export async function refreshAuthToken(): Promise { - return refreshStoredToken({ onlyIfExpiring: false }) +export async function refreshAuthToken( + accountEmail?: string, +): Promise { + return refreshStoredToken({ onlyIfExpiring: false, accountEmail }) } export type AuthorizedGoogleClient = { @@ -166,7 +178,9 @@ export type AuthorizedGoogleClient = { tokens?: StoredTokens } -export async function createGoogleAccessTokenClient(): Promise { +export async function createGoogleAccessTokenClient( + accountEmail?: string, +): Promise { const serviceAccount = getServiceAccountConfig() if (serviceAccount) { return { @@ -180,7 +194,7 @@ export async function createGoogleAccessTokenClient(): Promise configured: boolean sharedConfigured: boolean byoConfigured: boolean @@ -205,9 +220,11 @@ export async function authStatus(): Promise<{ }> { const status = getAuthModeStatus() const serviceAccount = getServiceAccountStatus() + const accounts = listGoogleAccounts() if (serviceAccount.configured || serviceAccount.error) { return { configured: serviceAccount.configured, + accounts, sharedConfigured: status.sharedConfigured, byoConfigured: status.byoConfigured, activeMode: 'service-account', @@ -218,6 +235,7 @@ export async function authStatus(): Promise<{ const tokens = await readTokens() return { tokens, + accounts, configured: Boolean(getClientConfig(tokens?.client_source)), sharedConfigured: status.sharedConfigured, byoConfigured: status.byoConfigured, diff --git a/packages/core/src/gsc/auth/loopback-callback.test.ts b/packages/core/src/gsc/auth/loopback-callback.test.ts new file mode 100644 index 00000000..311ef61c --- /dev/null +++ b/packages/core/src/gsc/auth/loopback-callback.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import http from 'node:http' +import test from 'node:test' +import { waitForCode } from './loopback-callback.js' + +async function loopbackServer(): Promise<{ + server: http.Server + redirectUri: string +}> { + const server = http.createServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + assert.ok(address && typeof address !== 'string') + return { + server, + redirectUri: `http://127.0.0.1:${address.port}/callback`, + } +} + +test('a stale OAuth callback does not stop the current login', async (t) => { + const { server, redirectUri } = await loopbackServer() + t.after(() => server.close()) + + const currentCallback = waitForCode({ + server, + redirectUri, + state: 'current-state', + }) + const staleResponse = await fetch( + `${redirectUri}?state=stale-state&code=stale-code`, + ) + assert.equal(staleResponse.status, 400) + + const responsePromise = fetch( + `${redirectUri}?state=current-state&code=current-code`, + ) + const callback = await currentCallback + assert.equal(callback.code, 'current-code') + callback.respond(200, 'Connected.') + + const response = await responsePromise + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Connected.') +}) diff --git a/packages/core/src/gsc/auth/loopback-callback.ts b/packages/core/src/gsc/auth/loopback-callback.ts new file mode 100644 index 00000000..ae4a2ba8 --- /dev/null +++ b/packages/core/src/gsc/auth/loopback-callback.ts @@ -0,0 +1,67 @@ +import type http from 'node:http' +import { oauthCallbackPage } from './callback-page.js' + +export function waitForCode(input: { + server: http.Server + redirectUri: string + state: string +}): Promise<{ + code: string + respond: (status: number, page: string) => void +}> { + return new Promise<{ + code: string + respond: (status: number, page: string) => void + }>((resolve, reject) => { + const callbackPath = new URL(input.redirectUri).pathname + const timer = setTimeout( + () => reject(new Error('OAuth flow timed out after 5 minutes.')), + 300_000, + ) + + input.server.on('request', (req, res) => { + try { + const reqUrl = new URL(req.url ?? '/', input.redirectUri) + if (reqUrl.pathname !== callbackPath) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('Not found.') + return + } + if (reqUrl.searchParams.get('state') !== input.state) { + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + res.end(oauthCallbackPage({ status: 'failed' })) + return + } + + const error = reqUrl.searchParams.get('error') + if (error) { + throw new Error(`OAuth error: ${error}`) + } + + const incomingCode = reqUrl.searchParams.get('code') + if (!incomingCode) { + throw new Error('OAuth code missing.') + } + + let responded = false + clearTimeout(timer) + resolve({ + code: incomingCode, + respond: (status, page) => { + if (responded) return + responded = true + res.writeHead(status, { + 'content-type': 'text/html; charset=utf-8', + }) + res.end(page) + }, + }) + } catch (error) { + res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) + res.end(oauthCallbackPage({ status: 'failed' })) + clearTimeout(timer) + reject(error) + } + }) + }) +} diff --git a/packages/core/src/gsc/auth/loopback.ts b/packages/core/src/gsc/auth/loopback.ts index 420aed8a..b95f77d5 100644 --- a/packages/core/src/gsc/auth/loopback.ts +++ b/packages/core/src/gsc/auth/loopback.ts @@ -6,6 +6,7 @@ import { writeTokens } from '../../storage/config.js' import type { StoredTokens } from '../../types.js' import { oauthCallbackPage } from './callback-page.js' import { getAuthModeStatus, getClientConfig } from './client-config.js' +import { waitForCode } from './loopback-callback.js' import { GOOGLE_TOKEN_ENDPOINT } from './token-endpoint.js' import { GOOGLE_READONLY_SCOPE_LABELS, @@ -54,7 +55,7 @@ export async function loginWithLoopback( response_type: 'code', scope: GOOGLE_SCOPE, access_type: 'offline', - prompt: 'consent', + prompt: 'select_account consent', state, code_challenge: challenge, code_challenge_method: 'S256', @@ -119,61 +120,6 @@ export async function loginWithLoopback( } } -function waitForCode(input: { - server: http.Server - redirectUri: string - state: string -}): Promise<{ - code: string - respond: (status: number, page: string) => void -}> { - return new Promise<{ - code: string - respond: (status: number, page: string) => void - }>((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('OAuth flow timed out after 5 minutes.')), - 300_000, - ) - input.server.on('request', (req, res) => { - try { - const reqUrl = new URL(req.url ?? '/', input.redirectUri) - if (reqUrl.searchParams.get('state') !== input.state) { - throw new Error('OAuth state mismatch.') - } - const error = reqUrl.searchParams.get('error') - if (error) { - throw new Error(`OAuth error: ${error}`) - } - - const incomingCode = reqUrl.searchParams.get('code') - if (!incomingCode) { - throw new Error('OAuth code missing.') - } - - let responded = false - clearTimeout(timer) - resolve({ - code: incomingCode, - respond: (status, page) => { - if (responded) return - responded = true - res.writeHead(status, { - 'content-type': 'text/html; charset=utf-8', - }) - res.end(page) - }, - }) - } catch (error) { - res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }) - res.end(oauthCallbackPage({ status: 'failed' })) - clearTimeout(timer) - reject(error) - } - }) - }) -} - async function exchangeCode(input: { clientConfig: OAuthClientConfig code: string diff --git a/packages/core/src/gsc/client/fetch.ts b/packages/core/src/gsc/client/fetch.ts index a13fcdc9..859599b4 100644 --- a/packages/core/src/gsc/client/fetch.ts +++ b/packages/core/src/gsc/client/fetch.ts @@ -29,6 +29,6 @@ export async function authedFetch( }) } -export async function getAuthorized() { - return createGoogleAccessTokenClient() +export async function getAuthorized(accountEmail?: string) { + return createGoogleAccessTokenClient(accountEmail) } diff --git a/packages/core/src/gsc/client/inspection.ts b/packages/core/src/gsc/client/inspection.ts index 2bd52e5e..bfcba289 100644 --- a/packages/core/src/gsc/client/inspection.ts +++ b/packages/core/src/gsc/client/inspection.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { SeoError } from '../../errors.js' +import { searchConsoleAccountForSite } from '../auth/account-selection.js' import { assertUrlMatchesGscProperty } from '../property-url.js' import { authedFetch, getAuthorized } from './fetch.js' import { @@ -67,7 +68,9 @@ export async function inspectUrl( input.siteUrl, input.inspectionUrl, ) - const { client, quotaIdentity } = await getAuthorized() + const { client, quotaIdentity } = await getAuthorized( + input.accountEmail ?? searchConsoleAccountForSite(input.siteUrl), + ) const reservation = reserveUrlInspectionQuota({ credentialKey: credentialKey(quotaIdentity), property: input.siteUrl, diff --git a/packages/core/src/gsc/client/search-analytics.ts b/packages/core/src/gsc/client/search-analytics.ts index db3beefa..5f2b93b3 100644 --- a/packages/core/src/gsc/client/search-analytics.ts +++ b/packages/core/src/gsc/client/search-analytics.ts @@ -1,6 +1,7 @@ import { SeoError } from '../../errors.js' import { getDb, hashKey, noteCacheWrite } from '../../storage/database.js' import type { GscRow } from '../../types.js' +import { searchConsoleAccountForSite } from '../auth/account-selection.js' import { finalGscDateRange } from '../dates.js' import { authedFetch, getAuthorized } from './fetch.js' import type { SearchAnalyticsRequest } from './types.js' @@ -38,11 +39,13 @@ function resolveSearchDateWindow( export async function querySearchAnalytics( site: string, body: SearchAnalyticsRequest, - opts: { refresh?: boolean } = {}, + opts: { refresh?: boolean; accountEmail?: string } = {}, ): Promise<{ rows: GscRow[]; calls: number; rowsFetched: number }> { - const { client } = await getAuthorized() + const { client, identity } = await getAuthorized( + opts.accountEmail ?? searchConsoleAccountForSite(site), + ) const db = getDb() - const queryHash = hashKey([site, body]) + const queryHash = hashKey([site, identity, body]) const cached = db .prepare( 'SELECT response_json, row_count FROM gsc_cache WHERE site_url = ? AND query_hash = ? AND expires_at > ?', diff --git a/packages/core/src/gsc/client/sites.ts b/packages/core/src/gsc/client/sites.ts index ae885ece..3ae77705 100644 --- a/packages/core/src/gsc/client/sites.ts +++ b/packages/core/src/gsc/client/sites.ts @@ -3,10 +3,11 @@ import { authedFetch, getAuthorized } from './fetch.js' export async function listSites( _refresh = false, + accountEmail?: string, ): Promise< Array<{ siteUrl: string; permissionLevel?: string; siteType?: string }> > { - const { client } = await getAuthorized() + const { client } = await getAuthorized(accountEmail) const response = await authedFetch( client, 'https://www.googleapis.com/webmasters/v3/sites', diff --git a/packages/core/src/gsc/client/types.ts b/packages/core/src/gsc/client/types.ts index 10c501ae..31f527d9 100644 --- a/packages/core/src/gsc/client/types.ts +++ b/packages/core/src/gsc/client/types.ts @@ -27,6 +27,7 @@ export interface SearchAnalyticsRequest { export interface UrlInspectionRequest { siteUrl: string inspectionUrl: string + accountEmail?: string languageCode?: string quotaLimit?: number } diff --git a/packages/core/src/storage/config.test.ts b/packages/core/src/storage/config.test.ts index 3600deae..c91792d9 100644 --- a/packages/core/src/storage/config.test.ts +++ b/packages/core/src/storage/config.test.ts @@ -7,6 +7,7 @@ import { readFileSync, rmSync, statSync, + writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -17,12 +18,19 @@ import { getProviderSpendLimits, setProviderSpendLimits, } from '../providers/cost-limits.js' -import { configSchema, type StoredTokens } from '../types.js' +import { + configSchema, + type StoredTokenStore, + type StoredTokens, +} from '../types.js' import { deleteTokens, getTokenStorageStatus, + listGoogleAccounts, + readAllTokens, readConfig, readTokens, + setActiveGoogleAccount, setTokenStorageMode, writeConfig, writeTokens, @@ -159,8 +167,8 @@ test('moves a private token file into the system keychain', async () => { const before = JSON.parse( readFileSync(getSeoCliPaths().tokensFile, 'utf8'), - ) as StoredTokens - assert.equal(before.refresh_token, 'refresh-token') + ) as StoredTokenStore + assert.equal(before.accounts[0]?.refresh_token, 'refresh-token') const storage = await setTokenStorageMode('keychain') assert.deepEqual(storage, { configured: 'keychain', active: 'keychain' }) @@ -172,12 +180,150 @@ test('moves a private token file into the system keychain', async () => { const after = JSON.parse( readFileSync(getSeoCliPaths().tokensFile, 'utf8'), - ) as StoredTokens - assert.equal(after.access_token, undefined) - assert.equal(after.refresh_token, undefined) + ) as StoredTokenStore + assert.equal(after.accounts[0]?.access_token, undefined) + assert.equal(after.accounts[0]?.refresh_token, undefined) assert.equal(mode(getSeoCliPaths().tokensFile), 0o600) }) +test('keeps several Google accounts and selects one active account', async () => { + resetStorage() + writeConfig(configSchema.parse({ security: { useKeychain: false } })) + const first = testTokens() + const second = { + ...testTokens(), + account_email: 'analytics@example.com', + access_token: 'analytics-access-token', + refresh_token: 'analytics-refresh-token', + } + + await writeTokens(first) + await writeTokens(second) + + assert.equal((await readTokens())?.account_email, 'analytics@example.com') + assert.deepEqual( + (await readAllTokens()).map((tokens) => tokens.account_email), + ['owner@example.com', 'analytics@example.com'], + ) + assert.deepEqual( + listGoogleAccounts().map((account) => ({ + email: account.accountEmail, + active: account.active, + })), + [ + { email: 'owner@example.com', active: false }, + { email: 'analytics@example.com', active: true }, + ], + ) + + setActiveGoogleAccount('OWNER@example.com') + assert.equal((await readTokens())?.account_email, 'owner@example.com') + assert.equal( + (await readTokens('analytics@example.com'))?.access_token, + 'analytics-access-token', + ) + + await deleteTokens('owner@example.com') + assert.equal((await readTokens())?.account_email, 'analytics@example.com') + assert.equal((await readAllTokens()).length, 1) +}) + +test('moves several Google accounts between file and keychain storage', async () => { + resetStorage() + writeConfig(configSchema.parse({ security: { useKeychain: false } })) + await writeTokens(testTokens()) + await writeTokens({ + ...testTokens(), + account_email: 'analytics@example.com', + access_token: 'analytics-access-token', + refresh_token: 'analytics-refresh-token', + }) + setActiveGoogleAccount('owner@example.com') + + assert.deepEqual(await setTokenStorageMode('keychain'), { + configured: 'keychain', + active: 'keychain', + }) + assert.equal((await readAllTokens()).length, 2) + assert.equal((await readTokens())?.account_email, 'owner@example.com') + assert.equal( + keyring.values.get('seo:google:analytics@example.com:refresh'), + 'analytics-refresh-token', + ) + + assert.deepEqual(await setTokenStorageMode('file'), { + configured: 'file', + active: 'file', + }) + assert.equal( + (await readTokens('analytics@example.com'))?.refresh_token, + 'analytics-refresh-token', + ) + assert.equal((await readTokens())?.account_email, 'owner@example.com') + assert.equal(keyring.values.size, 0) +}) + +test('limits local Google login storage to 50 accounts', async () => { + resetStorage() + writeConfig(configSchema.parse({ security: { useKeychain: false } })) + for (let index = 0; index < 50; index += 1) { + await writeTokens({ + ...testTokens(), + account_email: `account-${index}@example.com`, + }) + } + + await assert.rejects( + writeTokens({ + ...testTokens(), + account_email: 'account-50@example.com', + }), + /at most 50|too big/i, + ) + assert.equal((await readAllTokens()).length, 50) +}) + +test('reads a legacy single-account token file and migrates it on write', async () => { + resetStorage() + writeConfig(configSchema.parse({ security: { useKeychain: false } })) + const legacy = testTokens() + writeFileSync(getSeoCliPaths().tokensFile, JSON.stringify(legacy), { + mode: 0o600, + }) + + assert.deepEqual(await readTokens(), legacy) + await writeTokens({ + ...testTokens(), + account_email: 'second@example.com', + }) + + const stored = JSON.parse( + readFileSync(getSeoCliPaths().tokensFile, 'utf8'), + ) as StoredTokenStore + assert.equal(stored.version, 2) + assert.equal(stored.active_account, 'second@example.com') + assert.deepEqual( + stored.accounts.map((tokens) => tokens.account_email), + ['owner@example.com', 'second@example.com'], + ) +}) + +test('keeps an existing refresh token when Google omits it on login', async () => { + resetStorage() + writeConfig(configSchema.parse({ security: { useKeychain: false } })) + await writeTokens(testTokens()) + + await writeTokens({ + ...testTokens(), + access_token: 'new-access-token', + refresh_token: undefined, + }) + + const tokens = await readTokens('owner@example.com') + assert.equal(tokens?.access_token, 'new-access-token') + assert.equal(tokens?.refresh_token, 'refresh-token') +}) + test('falls back to a private token file when the keychain is unavailable', async () => { resetStorage() keyring.unavailable = true @@ -185,6 +331,8 @@ test('falls back to a private token file when the keychain is unavailable', asyn writeConfig(configSchema.parse({})) await writeTokens(tokens) + assert.deepEqual(await readTokens(), tokens) + keyring.unavailable = false assert.deepEqual(await readTokens(), tokens) assert.deepEqual(await getTokenStorageStatus(), { configured: 'keychain', diff --git a/packages/core/src/storage/config.ts b/packages/core/src/storage/config.ts index 603b2fc2..2ab6ebb6 100644 --- a/packages/core/src/storage/config.ts +++ b/packages/core/src/storage/config.ts @@ -3,8 +3,10 @@ import { ensureSeoCliDirs, getSeoCliPaths } from '../paths.js' import { type AppConfig, configSchema, + type StoredTokenStore, type StoredTokens, tokenSchema, + tokenStoreSchema, } from '../types.js' import { fileMode, readJsonFile, safeRemove, writeJsonAtomic } from './files.js' import { @@ -70,6 +72,36 @@ function tokenMetadata(tokens: StoredTokens): StoredTokens { } } +function sameAccount(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() +} + +function parseTokenStore(raw: unknown): StoredTokenStore | undefined { + if (raw === undefined || raw === null) return undefined + const store = tokenStoreSchema.safeParse(raw) + if (store.success) return store.data + const legacy = tokenSchema.safeParse(raw) + if (!legacy.success) return tokenStoreSchema.parse(raw) + return { + version: 2, + active_account: legacy.data.account_email, + accounts: [legacy.data], + } +} + +function readRawTokenStore(): StoredTokenStore | undefined { + ensureSeoCliDirs() + return parseTokenStore(readJsonFile(getSeoCliPaths().tokensFile)) +} + +function writeTokenStore(store: StoredTokenStore): void { + writeJsonAtomic( + getSeoCliPaths().tokensFile, + tokenStoreSchema.parse(store), + PRIVATE_FILE_MODE, + ) +} + async function readKeyringTokens(tokens: StoredTokens): Promise<{ accessToken?: string refreshToken?: string @@ -115,53 +147,113 @@ async function deleteKeyringTokens(tokens: StoredTokens): Promise { ]) } -export async function readTokens(): Promise { - ensureSeoCliDirs() - const raw = readJsonFile(getSeoCliPaths().tokensFile) - if (!raw) { - return undefined - } - - const parsed = tokenSchema.parse(raw) +async function hydrateTokens(tokens: StoredTokens): Promise { const config = readConfig() - if (!config.security.useKeychain) { - return parsed + if (!config.security.useKeychain || hasTokenSecrets(tokens)) { + return tokens } try { - const keyringTokens = await readKeyringTokens(parsed) - if (hasTokenSecrets(parsed)) { - await writeKeyringTokens(parsed) - writeJsonAtomic( - getSeoCliPaths().tokensFile, - tokenMetadata(parsed), - PRIVATE_FILE_MODE, - ) - return parsed - } + const keyringTokens = await readKeyringTokens(tokens) return { - ...parsed, + ...tokens, access_token: keyringTokens.accessToken, refresh_token: keyringTokens.refreshToken, } } catch { - return parsed + return tokens + } +} + +export async function readAllTokens(): Promise { + const store = readRawTokenStore() + if (!store) return [] + return Promise.all(store.accounts.map((tokens) => hydrateTokens(tokens))) +} + +export async function readTokens( + accountEmail?: string, +): Promise { + const store = readRawTokenStore() + if (!store) return undefined + const selected = accountEmail + ? store.accounts.find((tokens) => + sameAccount(tokens.account_email, accountEmail), + ) + : store.accounts.find((tokens) => + sameAccount(tokens.account_email, store.active_account), + ) + return selected ? hydrateTokens(selected) : undefined +} + +export function listGoogleAccounts(): Array<{ + accountEmail: string + scopes: string[] + expiresAt: number + active: boolean +}> { + const store = readRawTokenStore() + if (!store) return [] + return store.accounts.map((tokens) => ({ + accountEmail: tokens.account_email, + scopes: tokens.scope.split(/\s+/u).filter(Boolean), + expiresAt: tokens.expires_at, + active: sameAccount(tokens.account_email, store.active_account), + })) +} + +export function setActiveGoogleAccount(accountEmail: string): void { + const store = readRawTokenStore() + const account = store?.accounts.find((tokens) => + sameAccount(tokens.account_email, accountEmail), + ) + if (!store || !account) { + throw new Error(`Google account is not connected: ${accountEmail}`) } + writeTokenStore({ ...store, active_account: account.account_email }) } -export async function writeTokens(tokens: StoredTokens): Promise { +export async function writeTokens( + tokens: StoredTokens, + options: { makeActive?: boolean } = {}, +): Promise { ensureSeoCliDirs() - const parsed = tokenSchema.parse(tokens) + const parsedInput = tokenSchema.parse(tokens) const config = readConfig() + const current = readRawTokenStore() + const existing = current?.accounts.find((item) => + sameAccount(item.account_email, parsedInput.account_email), + ) + const existingTokens = existing ? await hydrateTokens(existing) : undefined + const parsed = tokenSchema.parse({ + ...parsedInput, + refresh_token: parsedInput.refresh_token ?? existingTokens?.refresh_token, + }) + const accounts = [ + ...(current?.accounts.filter( + (item) => !sameAccount(item.account_email, parsed.account_email), + ) ?? []), + parsed, + ] + const activeAccount = + options.makeActive === false && current + ? current.active_account + : parsed.account_email + const nextStore = tokenStoreSchema.parse({ + version: 2, + active_account: activeAccount, + accounts, + }) if (config.security.useKeychain) { try { - await writeKeyringTokens(parsed) - writeJsonAtomic( - getSeoCliPaths().tokensFile, - tokenMetadata(parsed), - PRIVATE_FILE_MODE, - ) + for (const item of accounts.filter(hasTokenSecrets)) { + await writeKeyringTokens(item) + } + writeTokenStore({ + ...nextStore, + accounts: nextStore.accounts.map(tokenMetadata), + }) return } catch { // A headless Linux host or a locked desktop keychain should not prevent @@ -169,30 +261,53 @@ export async function writeTokens(tokens: StoredTokens): Promise { } } - writeJsonAtomic(getSeoCliPaths().tokensFile, parsed, PRIVATE_FILE_MODE) + writeTokenStore(nextStore) } -export async function deleteTokens(): Promise { +export async function deleteTokens(accountEmail?: string): Promise { const path = getSeoCliPaths().tokensFile - const raw = readJsonFile(path) - if (!raw) { + const store = readRawTokenStore() + if (!store) { safeRemove(path) return } - const tokens = tokenSchema.parse(raw) - const keyringBacked = !hasTokenSecrets(tokens) - try { - await deleteKeyringTokens(tokens) - } catch (error) { - if (keyringBacked) { - throw new Error( - 'Google tokens could not be removed from the system keychain. Unlock the keychain and try again.', - { cause: error }, + const targets = accountEmail + ? store.accounts.filter((tokens) => + sameAccount(tokens.account_email, accountEmail), ) + : store.accounts + if (accountEmail && targets.length === 0) { + throw new Error(`Google account is not connected: ${accountEmail}`) + } + for (const tokens of targets) { + const keyringBacked = !hasTokenSecrets(tokens) + try { + await deleteKeyringTokens(tokens) + } catch (error) { + if (keyringBacked) { + throw new Error( + 'Google tokens could not be removed from the system keychain. Unlock the keychain and try again.', + { cause: error }, + ) + } } } - safeRemove(path) + if (!accountEmail || targets.length === store.accounts.length) { + safeRemove(path) + return + } + const accounts = store.accounts.filter( + (tokens) => !sameAccount(tokens.account_email, accountEmail), + ) + const activeAccount = sameAccount(store.active_account, accountEmail) + ? (accounts[0]?.account_email ?? '') + : store.active_account + writeTokenStore({ + version: 2, + active_account: activeAccount, + accounts, + }) } export async function getTokenStorageStatus(): Promise { @@ -203,10 +318,9 @@ export async function getTokenStorageStatus(): Promise { return { configured, active: 'file' } } - const raw = readJsonFile(getSeoCliPaths().tokensFile) - if (!raw) return { configured, active: 'keychain' } - const tokens = tokenSchema.parse(raw) - if (hasTokenSecrets(tokens)) { + const store = readRawTokenStore() + if (!store) return { configured, active: 'keychain' } + if (store.accounts.some(hasTokenSecrets)) { return { configured, active: 'file', @@ -216,7 +330,7 @@ export async function getTokenStorageStatus(): Promise { } try { - await readKeyringTokens(tokens) + await Promise.all(store.accounts.map((tokens) => readKeyringTokens(tokens))) return { configured, active: 'keychain' } } catch { return { @@ -231,17 +345,26 @@ export async function getTokenStorageStatus(): Promise { export async function setTokenStorageMode( mode: TokenStorageMode, ): Promise { - const tokens = await readTokens() + const tokens = await readAllTokens() + const activeAccount = listGoogleAccounts().find( + (account) => account.active, + )?.accountEmail const config = readConfig() writeConfig({ ...config, security: { ...config.security, useKeychain: mode === 'keychain' }, }) - if (tokens) { - await writeTokens(tokens) + if (tokens.length > 0) { + for (const item of tokens) { + await writeTokens(item, { + makeActive: sameAccount(item.account_email, activeAccount ?? ''), + }) + } if (mode === 'file') { - await deleteKeyringTokens(tokens).catch(() => undefined) + await Promise.all( + tokens.map((item) => deleteKeyringTokens(item).catch(() => undefined)), + ) } } diff --git a/packages/core/src/types/auth.ts b/packages/core/src/types/auth.ts index ba3f0ed1..27e928b5 100644 --- a/packages/core/src/types/auth.ts +++ b/packages/core/src/types/auth.ts @@ -2,14 +2,42 @@ import { z } from 'zod' export const tokenSchema = z.object({ provider: z.literal('google'), - account_email: z.string().email(), - scope: z.string(), - token_type: z.string(), - access_token: z.string().optional(), - refresh_token: z.string().optional(), + account_email: z.string().email().max(320), + scope: z.string().max(20_000), + token_type: z.string().max(100), + access_token: z.string().max(65_536).optional(), + refresh_token: z.string().max(65_536).optional(), expires_at: z.number().int(), obtained_at: z.number().int(), client_source: z.enum(['shared', 'byo']), }) export type StoredTokens = z.infer + +export const tokenStoreSchema = z + .object({ + version: z.literal(2), + active_account: z.string().email(), + accounts: z.array(tokenSchema).min(1).max(50), + }) + .superRefine((store, context) => { + const emails = store.accounts.map((tokens) => + tokens.account_email.toLowerCase(), + ) + if (new Set(emails).size !== emails.length) { + context.addIssue({ + code: 'custom', + path: ['accounts'], + message: 'Google account emails must be unique.', + }) + } + if (!emails.includes(store.active_account.toLowerCase())) { + context.addIssue({ + code: 'custom', + path: ['active_account'], + message: 'The active Google account must exist in accounts.', + }) + } + }) + +export type StoredTokenStore = z.infer diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 42dfa736..f005660c 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -45,6 +45,7 @@ export const analyticsConnectionSchema = z.discriminatedUnion('provider', [ z.object({ provider: z.literal('google'), propertyId: z.string(), + accountEmail: z.string().email().optional(), }), z.object({ provider: z.literal('clicky'), @@ -105,6 +106,12 @@ export const clientProfileSchema = z.object({ watchUrls: z.array(z.string()).default([]), brandTerms: z.array(z.string()).default([]), analytics: analyticsConnectionsSchema, + googleAccounts: z + .object({ + searchConsole: z.string().email().optional(), + googleAnalytics: z.string().email().optional(), + }) + .optional(), searchEngines: z .object({ bing: z diff --git a/packages/mcp/src/client-tools.ts b/packages/mcp/src/client-tools.ts index c5def864..22303385 100644 --- a/packages/mcp/src/client-tools.ts +++ b/packages/mcp/src/client-tools.ts @@ -60,7 +60,9 @@ export function registerClientTools(server: McpServer): void { site: z.string().optional(), startUrl: z.string().url().optional(), watchUrls: z.array(z.string().url()).optional(), + searchConsoleAccountEmail: z.string().email().optional(), googleAnalyticsPropertyId: z.string().optional(), + googleAnalyticsAccountEmail: z.string().email().optional(), clickySiteId: z .string() .regex(/^\d{1,30}$/u) @@ -77,7 +79,9 @@ export function registerClientTools(server: McpServer): void { site, startUrl, watchUrls, + searchConsoleAccountEmail, googleAnalyticsPropertyId, + googleAnalyticsAccountEmail, clickySiteId, reportDay, technicalWeekday, @@ -122,6 +126,13 @@ export function registerClientTools(server: McpServer): void { : clickySiteId ? { selected: 'clicky', clicky: { siteId: clickySiteId } } : undefined, + googleAccounts: + searchConsoleAccountEmail || googleAnalyticsAccountEmail + ? { + searchConsole: searchConsoleAccountEmail, + googleAnalytics: googleAnalyticsAccountEmail, + } + : undefined, reportDay, technicalWeekday, isDefault, diff --git a/packages/mcp/src/data-tools.ts b/packages/mcp/src/data-tools.ts index 25d9add5..66745cfc 100644 --- a/packages/mcp/src/data-tools.ts +++ b/packages/mcp/src/data-tools.ts @@ -17,11 +17,13 @@ export function registerDataTools(server: McpServer): void { { description: 'List Google Analytics accounts and properties available to Google OAuth', - inputSchema: {}, + inputSchema: { + accountEmail: z.string().email().optional(), + }, }, - async () => { + async ({ accountEmail }) => { try { - const accountSummaries = await listGa4AccountSummaries() + const accountSummaries = await listGa4AccountSummaries(accountEmail) const properties = accountSummaries.flatMap((account) => account.propertySummaries.map((property) => ({ account: account.displayName ?? account.account, @@ -48,12 +50,15 @@ export function registerDataTools(server: McpServer): void { description: 'Raw Search Console searchAnalytics/query passthrough', inputSchema: { site: z.string(), + accountEmail: z.string().email().optional(), body: z.record(z.string(), z.any()), }, }, - async ({ site, body }) => { + async ({ site, accountEmail, body }) => { try { - const result = await querySearchAnalytics(site, body as never) + const result = await querySearchAnalytics(site, body as never, { + accountEmail, + }) return toolSuccess( `Fetched ${result.rows.length} Search Console rows.`, result, @@ -72,14 +77,16 @@ export function registerDataTools(server: McpServer): void { inputSchema: { site: z.string(), url: z.string().url(), + accountEmail: z.string().email().optional(), languageCode: z.string().optional(), }, }, - async ({ site, url, languageCode }) => { + async ({ site, url, accountEmail, languageCode }) => { try { const result = await inspectUrl({ siteUrl: site, inspectionUrl: url, + accountEmail, languageCode, }) const status = result.inspectionResult?.indexStatusResult @@ -100,12 +107,15 @@ export function registerDataTools(server: McpServer): void { 'Run a Google Analytics Data API report for a property the signed-in user can access', inputSchema: { propertyId: z.string(), + accountEmail: z.string().email().optional(), body: z.record(z.string(), z.any()), }, }, - async ({ propertyId, body }) => { + async ({ propertyId, accountEmail, body }) => { try { - const result = await runGa4Report(propertyId, body as never) + const result = await runGa4Report(propertyId, body as never, { + accountEmail, + }) return toolSuccess( `Fetched ${result.rowCount ?? result.rows?.length ?? 0} Google Analytics rows.`, {