diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 775ed55f7e5..b0f1ec0302d 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -24,6 +24,7 @@ import { logAndThrowError, logJson, exit, + getRequestUserAgent, getToken, log, version, @@ -670,7 +671,7 @@ export default class BaseCommand extends Command { host?: string pathPrefix?: string } = { - userAgent: USER_AGENT, + userAgent: getRequestUserAgent(), } if (process.env.NETLIFY_API_URL) { diff --git a/src/recipes/ai-context/context.ts b/src/recipes/ai-context/context.ts index 5e85eb9c08e..e74d86a8149 100644 --- a/src/recipes/ai-context/context.ts +++ b/src/recipes/ai-context/context.ts @@ -221,8 +221,11 @@ export const deleteFile = async (path: string) => { } } -export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { command }: RunRecipeOptions) => { - await Promise.allSettled( +export const downloadAndWriteContextFiles = async ( + consumer: ConsumerConfig, + { command }: RunRecipeOptions, +): Promise => { + const results = await Promise.allSettled( Object.keys(consumer.contextScopes).map(async (contextKey) => { const contextConfig = consumer.contextScopes[contextKey] @@ -264,7 +267,7 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c absoluteFilePath, )} contains the latest version of the context files.`, ) - return + return false } // We must preserve any overrides found in the existing file. @@ -289,6 +292,14 @@ export const downloadAndWriteContextFiles = async (consumer: ConsumerConfig, { c await writeFile(absoluteFilePath, contents) log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`) + return true }), ) + + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure) { + throw failure.reason + } + + return results.some((result) => result.status === 'fulfilled' && result.value) } diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 8063062075d..ebfa437bf82 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -5,6 +5,7 @@ import execa from 'execa' import type { RunRecipeOptions } from '../../commands/recipes/recipes.js' import { logAndThrowError, log, version } from '../../utils/command-helpers.js' +import { track } from '../../utils/telemetry/index.js' import { getExistingContext, @@ -156,8 +157,9 @@ export const run = async (runOptions: RunRecipeOptions) => { return } + let wroteFiles = false try { - await downloadAndWriteContextFiles(consumer, runOptions) + wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions) // the deprecated MCP file path // let's remove that file if it exists. @@ -171,4 +173,8 @@ export const run = async (runOptions: RunRecipeOptions) => { } catch (error) { logAndThrowError(error) } + + if (wroteFiles) { + await track('sites_aiContextInstalled', { consumer: consumer.key }) + } } diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..2b3faf08674 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -12,6 +12,7 @@ import terminalLink from 'terminal-link' import { startSpinner } from '../lib/spinner.js' +import { getDrivingAgent } from './agent-detection.js' import getCLIPackageJson from './get-cli-package-json.js' import { reportError } from './telemetry/report-error.js' import type { TokenLocation } from './types.js' @@ -54,6 +55,11 @@ const { name, version: packageVersion } = await getCLIPackageJson() export const version = packageVersion export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}` +export const getRequestUserAgent = (env: NodeJS.ProcessEnv = process.env): string => { + const agent = getDrivingAgent(env) + return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT +} + /** A list of base command flags that needs to be sorted down on documentation and on help pages */ const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename']) diff --git a/tests/unit/recipes/ai-context/download-context-files.test.ts b/tests/unit/recipes/ai-context/download-context-files.test.ts index af1bd102e7c..4a1a01a9f34 100644 --- a/tests/unit/recipes/ai-context/download-context-files.test.ts +++ b/tests/unit/recipes/ai-context/download-context-files.test.ts @@ -98,7 +98,7 @@ describe('downloadAndWriteContextFiles', () => { test('downloads and writes context files for all scopes', async () => { // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(true) // Verify expected calls expect(mockFetch).toHaveBeenCalledTimes(2) // Once for each scope @@ -124,12 +124,19 @@ describe('downloadAndWriteContextFiles', () => { fs.readFile.mockResolvedValue(mockProviderContent) // Execute the actual function - await downloadAndWriteContextFiles(mockConsumer, mockRunOptions) + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBe(false) // Verify expected behavior - no writes when versions match expect(fs.writeFile).not.toHaveBeenCalled() }) + test('reports no writes when the consumer has no context scopes', async () => { + await expect(downloadAndWriteContextFiles({ ...mockConsumer, contextScopes: {} }, mockRunOptions)).resolves.toBe( + false, + ) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + test('applies overrides when updating existing Netlify files', async () => { // Mock existing file with different version const existingContent = @@ -199,22 +206,25 @@ describe('downloadAndWriteContextFiles', () => { ) }) - test('handles download errors gracefully', async () => { + test('rejects when a context file cannot be downloaded', async () => { // Mock fetch to return not ok // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: false, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'An error occurred when pulling the latest context file', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) - test('checks CLI version compatibility', async () => { + test('rejects when the CLI is older than the minimum version', async () => { // Set higher minimum CLI version // @ts-expect-error mocking is not 100% consistent with full API and types for fetch.mockResolvedValue({ ok: true, + text: () => Promise.resolve(mockProviderContent), headers: { get: (header: string) => { if (header === 'x-cli-min-ver') return '2.0.0' // Higher than the mocked current version @@ -223,7 +233,9 @@ describe('downloadAndWriteContextFiles', () => { }, }) - // Execute the actual function and expect error - await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).resolves.toBeUndefined() + await expect(downloadAndWriteContextFiles(mockConsumer, mockRunOptions)).rejects.toThrow( + 'This command requires version 2.0.0', + ) + expect(fs.writeFile).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/recipes/ai-context/index.test.ts b/tests/unit/recipes/ai-context/index.test.ts new file mode 100644 index 00000000000..2c629978b12 --- /dev/null +++ b/tests/unit/recipes/ai-context/index.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import type { RunRecipeOptions } from '../../../../src/commands/recipes/recipes.js' + +const { cursorConsumer } = vi.hoisted(() => ({ + cursorConsumer: { + key: 'cursor', + presentedName: 'Cursor', + consumerProcessCmd: 'cursor', + path: './.cursor/rules', + ext: 'mdc', + contextScopes: { serverless: { scope: 'Serverless functions' } }, + }, +})) + +vi.mock('../../../../src/recipes/ai-context/context.js', () => ({ + NTL_DEV_MCP_FILE_NAME: 'netlify-development.mdc', + getContextConsumers: vi.fn().mockResolvedValue([cursorConsumer]), + downloadAndWriteContextFiles: vi.fn().mockResolvedValue(true), + getExistingContext: vi.fn().mockResolvedValue(null), + deleteFile: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), + logAndThrowError: vi.fn((error: unknown) => { + throw error + }), + version: '1.0.0', +})) + +vi.mock('../../../../src/utils/telemetry/index.js', () => ({ + track: vi.fn(), +})) + +vi.mock('inquirer', () => ({ + default: { prompt: vi.fn().mockResolvedValue({ consumerKey: 'cursor' }) }, +})) + +import { downloadAndWriteContextFiles } from '../../../../src/recipes/ai-context/context.js' +import { run } from '../../../../src/recipes/ai-context/index.js' +import { track } from '../../../../src/utils/telemetry/index.js' + +const runRecipe = () => run({ args: [], command: { workingDir: '/project' } } as unknown as RunRecipeOptions) + +beforeEach(() => { + vi.mocked(track).mockClear() + vi.stubEnv('AI_CONTEXT_SKIP_DETECTION', 'true') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('tracks sites_aiContextInstalled with the consumer the context was installed for', async () => { + await runRecipe() + + expect(track).toHaveBeenCalledWith('sites_aiContextInstalled', { consumer: 'cursor' }) +}) + +test('does not track an install when every context file was already current', async () => { + vi.mocked(downloadAndWriteContextFiles).mockResolvedValueOnce(false) + + await runRecipe() + + expect(track).not.toHaveBeenCalled() +}) + +test('does not track an install when writing the context files fails', async () => { + vi.mocked(downloadAndWriteContextFiles).mockRejectedValueOnce(new Error('download failed')) + + await expect(runRecipe()).rejects.toThrow('download failed') + expect(track).not.toHaveBeenCalled() +}) diff --git a/tests/unit/utils/command-helpers.test.ts b/tests/unit/utils/command-helpers.test.ts index 7e367db8141..5e29bb11e73 100644 --- a/tests/unit/utils/command-helpers.test.ts +++ b/tests/unit/utils/command-helpers.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from 'vitest' -import { normalizeConfig } from '../../../src/utils/command-helpers.js' +import { USER_AGENT, getRequestUserAgent, normalizeConfig } from '../../../src/utils/command-helpers.js' + +describe('getRequestUserAgent', () => { + test('appends only the agent name, without its version or source', () => { + expect(getRequestUserAgent({ AI_AGENT: 'claude-code@2.1.0' })).toBe(`${USER_AGENT} agent/claude`) + }) + + test('returns the User-Agent unchanged when no agent is detected', () => { + expect(getRequestUserAgent({})).toBe(USER_AGENT) + }) +}) describe('normalizeConfig', () => { test('should remove publish and publishOrigin property if publishOrigin is "default"', () => {