diff --git a/docs/index.md b/docs/index.md index e681df42f73..bb59d197062 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,15 @@ To get a list of available sub-commands, arguments & flags run netlify [command] help ``` +## Running the CLI from an AI agent + +Set `NETLIFY_AGENT` to the name of the AI agent or tool running the CLI, such as `claude-code` or `codex`, optionally with +a version (`my-agent@1.2.0`). Netlify uses it to attribute CLI usage and signups to that agent, and it takes precedence +over the markers agents set on their own, such as `AI_AGENT`. + +Use only a product name and version. Never put a token, session ID, or other sensitive value in it: the value is sent to +Netlify and can appear in the login URL the CLI prints. + ## Commands diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 8b4e9abfede..775ed55f7e5 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -38,6 +38,7 @@ import { handleOptionError, isOptionError } from '../utils/command-error-handler import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' +import { buildAuthorizeUrl } from '../utils/login-url.js' import openBrowser from '../utils/open-browser.js' import { isInteractive } from '../utils/scripted-commands.js' import { identify, reportError, setCommandForErrorReporting, track } from '../utils/telemetry/index.js' @@ -509,7 +510,6 @@ export default class BaseCommand extends Command { } async expensivelyAuthenticate() { - const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com' log(`Logging into your Netlify account...`) // Create ticket for auth @@ -517,8 +517,12 @@ export default class BaseCommand extends Command { clientId: CLIENT_ID, }) + if (!ticket.id) { + return logAndThrowError('Failed to create login ticket') + } + // Open browser for authentication - const authLink = `${webUI}/authorize?response_type=ticket&ticket=${ticket.id}` + const authLink = buildAuthorizeUrl(ticket.id) log(`Opening ${authLink}`) const browserOpened = await openBrowser({ url: authLink }) diff --git a/src/commands/login/login-request.ts b/src/commands/login/login-request.ts index 40086e383a8..6bedac81a5b 100644 --- a/src/commands/login/login-request.ts +++ b/src/commands/login/login-request.ts @@ -1,12 +1,11 @@ import { NetlifyAPI } from '@netlify/api' import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { buildAuthorizeUrl } from '../../utils/login-url.js' import { CLIENT_ID } from '../base-command.js' import type { NetlifyOptions } from '../types.js' export const loginRequest = async (message: string, apiOpts: NetlifyOptions['apiOpts']) => { - const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com' - const api = new NetlifyAPI('', apiOpts) const ticket = await api.createTicket({ clientId: CLIENT_ID, body: { message } }) @@ -15,7 +14,7 @@ export const loginRequest = async (message: string, apiOpts: NetlifyOptions['api return logAndThrowError('Failed to create login ticket') } const ticketId = ticket.id - const url = `${webUI}/authorize?response_type=ticket&ticket=${ticketId}` + const url = buildAuthorizeUrl(ticketId) logJson({ ticket_id: ticketId, diff --git a/src/utils/login-url.ts b/src/utils/login-url.ts new file mode 100644 index 00000000000..ca642062af5 --- /dev/null +++ b/src/utils/login-url.ts @@ -0,0 +1,30 @@ +import { getDrivingAgent } from './agent-detection.js' + +// By contract these two hold only a non-sensitive agent name[@version]. Every other marker's value is a flag, +// a session or run id, or a path, none of which may reach a URL. +const SOURCES_WITH_ANNOUNCED_VALUE = new Set(['NETLIFY_AGENT', 'AI_AGENT']) + +const sanitizeUtmTerm = (raw: string): string => raw.replace(/[^A-Za-z0-9_.:@-]/g, '').slice(0, 64) + +const getUtmTerm = (source: string, env: NodeJS.ProcessEnv): string => { + const value = SOURCES_WITH_ANNOUNCED_VALUE.has(source) ? env[source] : undefined + return sanitizeUtmTerm(value ? `${source}:${value}` : source) +} + +export const buildAuthorizeUrl = (ticketId: string, env: NodeJS.ProcessEnv = process.env): string => { + const webUI = env.NETLIFY_WEB_UI || 'https://app.netlify.com' + const params = new URLSearchParams({ + response_type: 'ticket', + ticket: ticketId, + utm_source: 'cli', + utm_campaign: 'integrations', + }) + + const agent = getDrivingAgent(env) + if (agent) { + params.set('utm_content', agent.name) + params.set('utm_term', getUtmTerm(agent.source, env)) + } + + return `${webUI}/authorize?${params.toString()}` +} diff --git a/tests/unit/commands/login/login-request.test.ts b/tests/unit/commands/login/login-request.test.ts index b6f119f52eb..30c9f168484 100644 --- a/tests/unit/commands/login/login-request.test.ts +++ b/tests/unit/commands/login/login-request.test.ts @@ -38,7 +38,7 @@ describe('loginRequest', () => { const output = stdoutOutput.join('') expect(output).toContain('Ticket ID: test-ticket-123') expect(output).toContain( - 'Authorize URL: https://app.netlify.com/authorize?response_type=ticket&ticket=test-ticket-123', + 'Authorize URL: https://app.netlify.com/authorize?response_type=ticket&ticket=test-ticket-123&utm_source=cli&utm_campaign=integrations', ) expect(output).toContain('netlify login --check test-ticket-123') expect(output).toContain('After user opens the authorization URL and approves, the login will be complete.') diff --git a/tests/unit/utils/login-url.test.ts b/tests/unit/utils/login-url.test.ts new file mode 100644 index 00000000000..3aa8e8dac8b --- /dev/null +++ b/tests/unit/utils/login-url.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'vitest' + +import { buildAuthorizeUrl } from '../../../src/utils/login-url.js' + +const paramsFor = (env: NodeJS.ProcessEnv) => new URL(buildAuthorizeUrl('ticket-123', env)).searchParams + +test('tags the URL with only the CLI source and campaign when no agent is detected', () => { + expect(buildAuthorizeUrl('ticket-123', {})).toBe( + 'https://app.netlify.com/authorize?response_type=ticket&ticket=ticket-123&utm_source=cli&utm_campaign=integrations', + ) +}) + +test('adds the agent name and the deciding variable with its value', () => { + const params = paramsFor({ AI_AGENT: 'claude-code_2-1-259_agent' }) + + expect(params.get('utm_source')).toBe('cli') + expect(params.get('utm_campaign')).toBe('integrations') + expect(params.get('utm_content')).toBe('claude') + expect(params.get('utm_term')).toBe('AI_AGENT:claude-code_2-1-259_agent') +}) + +test('reports an unrecognized agent as other and keeps its raw value in utm_term', () => { + const params = paramsFor({ AI_AGENT: 'brand-new-agent' }) + + expect(params.get('utm_content')).toBe('other') + expect(params.get('utm_term')).toBe('AI_AGENT:brand-new-agent') +}) + +test('keeps the name@version boundary of an announced value', () => { + const params = paramsFor({ AI_AGENT: 'codex@1.2.3' }) + + expect(params.get('utm_content')).toBe('codex') + expect(params.get('utm_term')).toBe('AI_AGENT:codex@1.2.3') +}) + +test('includes the NETLIFY_AGENT value in utm_term', () => { + const params = paramsFor({ NETLIFY_AGENT: 'claude-code' }) + + expect(params.get('utm_content')).toBe('claude') + expect(params.get('utm_term')).toBe('NETLIFY_AGENT:claude-code') +}) + +test('sends only the variable name for a presence-only marker', () => { + const params = paramsFor({ CODEX_CI: '1' }) + + expect(params.get('utm_content')).toBe('codex') + expect(params.get('utm_term')).toBe('CODEX_CI') +}) + +test('never puts a session id in utm_term', () => { + expect(paramsFor({ COPILOT_AGENT_SESSION_ID: 'session-abc-123' }).get('utm_term')).toBe('COPILOT_AGENT_SESSION_ID') +}) + +test('strips characters outside the allowed set from utm_term', () => { + expect(paramsFor({ AI_AGENT: 'my agent/v1!' }).get('utm_term')).toBe('AI_AGENT:myagentv1') +}) + +test('caps utm_term at 64 characters', () => { + expect(paramsFor({ AI_AGENT: 'x'.repeat(100) }).get('utm_term')).toBe(`AI_AGENT:${'x'.repeat(55)}`) +}) + +test('uses NETLIFY_WEB_UI as the base when set', () => { + expect(buildAuthorizeUrl('ticket-123', { NETLIFY_WEB_UI: 'https://custom.netlify.com' })).toMatch( + /^https:\/\/custom\.netlify\.com\/authorize\?response_type=ticket&ticket=ticket-123&/, + ) +})