Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`[email protected]`). 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

<!-- AUTO-GENERATED-CONTENT:START (GENERATE_COMMANDS_LIST) -->
Expand Down
8 changes: 6 additions & 2 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -509,16 +510,19 @@ 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
const ticket = await this.netlify.api.createTicket({
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 })
Expand Down
5 changes: 2 additions & 3 deletions src/commands/login/login-request.ts
Original file line number Diff line number Diff line change
@@ -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 } })
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/utils/login-url.ts
Original file line number Diff line number Diff line change
@@ -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()}`
}
2 changes: 1 addition & 1 deletion tests/unit/commands/login/login-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/utils/login-url.test.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' })

expect(params.get('utm_content')).toBe('codex')
expect(params.get('utm_term')).toBe('AI_AGENT:[email protected]')
})

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&/,
)
})
Loading