netlify-cli 27.5.1 → 27.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,10 +15,11 @@ import inquirer from 'inquirer';
15
15
  import inquirerAutocompletePrompt from 'inquirer-autocomplete-prompt';
16
16
  import { deepMerge, pick } from '../utils/object-utilities.js';
17
17
  import { getAgent } from '../lib/http-agent.js';
18
- import { NETLIFY_CYAN, USER_AGENT, chalk, logAndThrowError, logJson, exit, getToken, log, version, normalizeConfig, padLeft, pollForToken, sortOptions, warn, logError, } from '../utils/command-helpers.js';
18
+ import { NETLIFY_CYAN, USER_AGENT, chalk, logAndThrowError, logJson, exit, getRequestUserAgent, getToken, log, version, normalizeConfig, padLeft, pollForToken, sortOptions, warn, logError, } from '../utils/command-helpers.js';
19
19
  import { handleOptionError, isOptionError } from '../utils/command-error-handler.js';
20
20
  import { getFrameworksAPIPaths } from '../utils/frameworks-api.js';
21
21
  import { getSiteByName } from '../utils/get-site.js';
22
+ import { buildAuthorizeUrl } from '../utils/login-url.js';
22
23
  import openBrowser from '../utils/open-browser.js';
23
24
  import { isInteractive } from '../utils/scripted-commands.js';
24
25
  import { identify, reportError, setCommandForErrorReporting, track } from '../utils/telemetry/index.js';
@@ -374,14 +375,16 @@ export default class BaseCommand extends Command {
374
375
  }
375
376
  }
376
377
  async expensivelyAuthenticate() {
377
- const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com';
378
378
  log(`Logging into your Netlify account...`);
379
379
  // Create ticket for auth
380
380
  const ticket = await this.netlify.api.createTicket({
381
381
  clientId: CLIENT_ID,
382
382
  });
383
+ if (!ticket.id) {
384
+ return logAndThrowError('Failed to create login ticket');
385
+ }
383
386
  // Open browser for authentication
384
- const authLink = `${webUI}/authorize?response_type=ticket&ticket=${ticket.id}`;
387
+ const authLink = buildAuthorizeUrl(ticket.id);
385
388
  log(`Opening ${authLink}`);
386
389
  const browserOpened = await openBrowser({ url: authLink });
387
390
  if (!browserOpened && !isInteractive() && ticket.id) {
@@ -502,7 +505,7 @@ export default class BaseCommand extends Command {
502
505
  const state = new LocalState(this.workingDir);
503
506
  const [token] = await getToken(flags.auth);
504
507
  const apiUrlOpts = {
505
- userAgent: USER_AGENT,
508
+ userAgent: getRequestUserAgent(),
506
509
  };
507
510
  if (process.env.NETLIFY_API_URL) {
508
511
  const apiUrl = new URL(process.env.NETLIFY_API_URL);
@@ -1,15 +1,15 @@
1
1
  import { NetlifyAPI } from '@netlify/api';
2
2
  import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js';
3
+ import { buildAuthorizeUrl } from '../../utils/login-url.js';
3
4
  import { CLIENT_ID } from '../base-command.js';
4
5
  export const loginRequest = async (message, apiOpts) => {
5
- const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com';
6
6
  const api = new NetlifyAPI('', apiOpts);
7
7
  const ticket = await api.createTicket({ clientId: CLIENT_ID, body: { message } });
8
8
  if (!ticket.id) {
9
9
  return logAndThrowError('Failed to create login ticket');
10
10
  }
11
11
  const ticketId = ticket.id;
12
- const url = `${webUI}/authorize?response_type=ticket&ticket=${ticketId}`;
12
+ const url = buildAuthorizeUrl(ticketId);
13
13
  logJson({
14
14
  ticket_id: ticketId,
15
15
  url,
@@ -54,6 +54,6 @@ export declare const applyOverrides: (template: string, overrides?: string) => s
54
54
  export declare const getExistingContext: (path: string) => Promise<ParsedContextFile | null>;
55
55
  export declare const writeFile: (path: string, contents: string) => Promise<void>;
56
56
  export declare const deleteFile: (path: string) => Promise<void>;
57
- export declare const downloadAndWriteContextFiles: (consumer: ConsumerConfig, { command }: RunRecipeOptions) => Promise<void>;
57
+ export declare const downloadAndWriteContextFiles: (consumer: ConsumerConfig, { command }: RunRecipeOptions) => Promise<boolean>;
58
58
  export {};
59
59
  //# sourceMappingURL=context.d.ts.map
@@ -148,7 +148,7 @@ export const deleteFile = async (path) => {
148
148
  }
149
149
  };
150
150
  export const downloadAndWriteContextFiles = async (consumer, { command }) => {
151
- await Promise.allSettled(Object.keys(consumer.contextScopes).map(async (contextKey) => {
151
+ const results = await Promise.allSettled(Object.keys(consumer.contextScopes).map(async (contextKey) => {
152
152
  const contextConfig = consumer.contextScopes[contextKey];
153
153
  const { contents: downloadedFile, minimumCLIVersion } = (await downloadFile(version, contextConfig, consumer).catch(() => null)) ?? {};
154
154
  if (!downloadedFile) {
@@ -168,7 +168,7 @@ export const downloadAndWriteContextFiles = async (consumer, { command }) => {
168
168
  if (existing.provider?.toLowerCase() === NETLIFY_PROVIDER) {
169
169
  if (remote.version === existing.version) {
170
170
  log(`You're all up to date! ${chalk.underline(absoluteFilePath)} contains the latest version of the context files.`);
171
- return;
171
+ return false;
172
172
  }
173
173
  // We must preserve any overrides found in the existing file.
174
174
  contents = applyOverrides(remote.contents, existing.overrides?.innerContents);
@@ -190,6 +190,12 @@ export const downloadAndWriteContextFiles = async (consumer, { command }) => {
190
190
  }
191
191
  await writeFile(absoluteFilePath, contents);
192
192
  log(`${existing ? 'Updated' : 'Created'} context files at ${chalk.underline(absoluteFilePath)}`);
193
+ return true;
193
194
  }));
195
+ const failure = results.find((result) => result.status === 'rejected');
196
+ if (failure) {
197
+ throw failure.reason;
198
+ }
199
+ return results.some((result) => result.status === 'fulfilled' && result.value);
194
200
  };
195
201
  //# sourceMappingURL=context.js.map
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
2
2
  import inquirer from 'inquirer';
3
3
  import execa from 'execa';
4
4
  import { logAndThrowError, log, version } from '../../utils/command-helpers.js';
5
+ import { track } from '../../utils/telemetry/index.js';
5
6
  import { getExistingContext, NTL_DEV_MCP_FILE_NAME, getContextConsumers, deleteFile, downloadAndWriteContextFiles, } from './context.js';
6
7
  export const description = 'Manage context files for AI tools';
7
8
  // context consumers endpoints returns all supported IDE and other consumers
@@ -114,8 +115,9 @@ export const run = async (runOptions) => {
114
115
  log('No context files found for this consumer. Try again or let us know if this happens again via our support channels.');
115
116
  return;
116
117
  }
118
+ let wroteFiles = false;
117
119
  try {
118
- await downloadAndWriteContextFiles(consumer, runOptions);
120
+ wroteFiles = await downloadAndWriteContextFiles(consumer, runOptions);
119
121
  // the deprecated MCP file path
120
122
  // let's remove that file if it exists.
121
123
  const priorContextFilePath = resolve(command?.workingDir ?? '', consumer.path, NTL_DEV_MCP_FILE_NAME);
@@ -128,5 +130,8 @@ export const run = async (runOptions) => {
128
130
  catch (error) {
129
131
  logAndThrowError(error);
130
132
  }
133
+ if (wroteFiles) {
134
+ await track('sites_aiContextInstalled', { consumer: consumer.key });
135
+ }
131
136
  };
132
137
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ export declare const CANONICAL_AGENT_NAMES: readonly ['claude', 'codex', 'copilot', 'gemini', 'cursor', 'opencode', 'kiro', 'cline', 'amp', 'warp', 'claudeai', 'chatgpt', 'other'];
2
+ export type CanonicalAgentName = (typeof CANONICAL_AGENT_NAMES)[number];
3
+ export type DrivingAgent = {
4
+ name: CanonicalAgentName;
5
+ source: string;
6
+ version?: string;
7
+ markers?: CanonicalAgentName[];
8
+ otherValue?: string;
9
+ };
10
+ export declare const getDrivingAgent: (env?: NodeJS.ProcessEnv) => DrivingAgent | undefined;
11
+ //# sourceMappingURL=agent-detection.d.ts.map
@@ -0,0 +1,151 @@
1
+ // Only markers an agent product sets on its own count as a signal; never infer from process names, terminals, or the process tree.
2
+ // The result is untrusted attribution from the environment, for telemetry and labeling only, never for authorization.
3
+ export const CANONICAL_AGENT_NAMES = [
4
+ 'claude',
5
+ 'codex',
6
+ 'copilot',
7
+ 'gemini',
8
+ 'cursor',
9
+ 'opencode',
10
+ 'kiro',
11
+ 'cline',
12
+ 'amp',
13
+ 'warp',
14
+ 'claudeai',
15
+ 'chatgpt',
16
+ 'other',
17
+ ];
18
+ const ANNOUNCED_NAME_TABLE = new Map([
19
+ ...CANONICAL_AGENT_NAMES.filter((name) => name !== 'other').map((name) => [name, name]),
20
+ ['claude-code', 'claude'],
21
+ ['claude-ai', 'claudeai'],
22
+ ['github-copilot', 'copilot'],
23
+ ['github-copilot-cli', 'copilot'],
24
+ ['github-copilot-vscode-agent', 'copilot'],
25
+ ['cursor-cli', 'cursor'],
26
+ ['gemini-cli', 'gemini'],
27
+ ['kiro-cli', 'kiro'],
28
+ ['warp-oz', 'warp'],
29
+ ]);
30
+ const lookupAnnouncedName = (key) => ANNOUNCED_NAME_TABLE.get(key.replace(/_/g, '-'));
31
+ const sanitizeAnnouncedValue = (raw) => raw.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64);
32
+ const nonEmpty = (value) => (value ? value : undefined);
33
+ const parseAnnouncedName = (raw) => {
34
+ const atIndex = raw.indexOf('@');
35
+ const sanitized = sanitizeAnnouncedValue(atIndex === -1 ? raw : raw.slice(0, atIndex));
36
+ if (sanitized === '') {
37
+ return undefined;
38
+ }
39
+ const announcedVersion = atIndex === -1 ? undefined : nonEmpty(sanitizeAnnouncedValue(raw.slice(atIndex + 1)));
40
+ const key = sanitized.toLowerCase();
41
+ const exact = lookupAnnouncedName(key);
42
+ if (exact) {
43
+ return { name: exact, version: announcedVersion };
44
+ }
45
+ const withoutAgentSuffix = key.replace(/_agent$/, '');
46
+ const suffixMatch = lookupAnnouncedName(withoutAgentSuffix);
47
+ if (suffixMatch) {
48
+ return { name: suffixMatch, version: announcedVersion };
49
+ }
50
+ const lastUnderscore = withoutAgentSuffix.lastIndexOf('_');
51
+ if (lastUnderscore !== -1) {
52
+ const head = withoutAgentSuffix.slice(0, lastUnderscore);
53
+ const headMatch = lookupAnnouncedName(head);
54
+ if (headMatch) {
55
+ const tail = withoutAgentSuffix.slice(lastUnderscore + 1);
56
+ return { name: headMatch, version: announcedVersion ?? tail.replace(/-/g, '.') };
57
+ }
58
+ }
59
+ return { name: 'other', otherValue: sanitized, version: announcedVersion };
60
+ };
61
+ // Precedence, first match wins: NETLIFY_AGENT (explicit, even when unknown); markers only the process
62
+ // running the command sets; AI_AGENT (explicit, even when unknown); markers inherited from an agent
63
+ // session; runner/task markers such as Warp's last, since the agent inside the run is the more specific answer.
64
+ const SIGNALS = [
65
+ {
66
+ source: 'NETLIFY_AGENT',
67
+ detect: (env) => (env.NETLIFY_AGENT === undefined ? undefined : parseAnnouncedName(env.NETLIFY_AGENT)),
68
+ },
69
+ {
70
+ source: 'CODEX_CI',
71
+ detect: (env) => (env.CODEX_CI === '1' ? { name: 'codex' } : undefined),
72
+ },
73
+ {
74
+ source: 'GEMINI_CLI',
75
+ detect: (env) => (env.GEMINI_CLI === '1' ? { name: 'gemini' } : undefined),
76
+ },
77
+ {
78
+ source: 'COPILOT_CLI',
79
+ detect: (env) => (env.COPILOT_CLI === '1' ? { name: 'copilot' } : undefined),
80
+ },
81
+ {
82
+ source: 'COPILOT_AGENT_SESSION_ID',
83
+ detect: (env) => (nonEmpty(env.COPILOT_AGENT_SESSION_ID) === undefined ? undefined : { name: 'copilot' }),
84
+ },
85
+ {
86
+ source: 'OPENCODE',
87
+ detect: (env) => env.OPENCODE === '1' && nonEmpty(env.OPENCODE_TERMINAL) === undefined ? { name: 'opencode' } : undefined,
88
+ },
89
+ {
90
+ source: 'AGENT_DISPLAY_OUT',
91
+ detect: (env) => nonEmpty(env.AGENT_DISPLAY_OUT) !== undefined && nonEmpty(env.AGENT_CONTEXT_OUT) !== undefined
92
+ ? { name: 'kiro' }
93
+ : undefined,
94
+ },
95
+ {
96
+ source: 'AI_AGENT',
97
+ detect: (env) => (env.AI_AGENT === undefined ? undefined : parseAnnouncedName(env.AI_AGENT)),
98
+ },
99
+ {
100
+ source: 'COPILOT_AGENT',
101
+ detect: (env) => (env.COPILOT_AGENT === '1' ? { name: 'copilot' } : undefined),
102
+ },
103
+ {
104
+ source: 'CURSOR_AGENT',
105
+ detect: (env) => (env.CURSOR_AGENT === '1' ? { name: 'cursor' } : undefined),
106
+ },
107
+ {
108
+ source: 'CLINE_ACTIVE',
109
+ detect: (env) => (env.CLINE_ACTIVE === 'true' ? { name: 'cline' } : undefined),
110
+ },
111
+ {
112
+ source: 'AGENT',
113
+ detect: (env) => (env.AGENT === 'amp' ? { name: 'amp' } : undefined),
114
+ },
115
+ {
116
+ source: 'CLAUDE_CODE_CHILD_SESSION',
117
+ detect: (env) => (env.CLAUDE_CODE_CHILD_SESSION === '1' ? { name: 'claude' } : undefined),
118
+ },
119
+ {
120
+ source: 'OZ_RUN_ID',
121
+ detect: (env) => (nonEmpty(env.OZ_RUN_ID) === undefined ? undefined : { name: 'warp' }),
122
+ },
123
+ {
124
+ source: 'WARP_RUN_ID',
125
+ detect: (env) => (nonEmpty(env.WARP_RUN_ID) === undefined ? undefined : { name: 'warp' }),
126
+ },
127
+ ];
128
+ export const getDrivingAgent = (env = process.env) => {
129
+ const matches = [];
130
+ for (const signal of SIGNALS) {
131
+ const result = signal.detect(env);
132
+ if (result) {
133
+ matches.push({ source: signal.source, ...result });
134
+ }
135
+ }
136
+ if (matches.length === 0) {
137
+ return undefined;
138
+ }
139
+ const [winner] = matches;
140
+ const codexVersion = winner.source === 'CODEX_CI' ? nonEmpty(env.CODEX_VERSION) : undefined;
141
+ const version = winner.version ?? (codexVersion === undefined ? undefined : sanitizeAnnouncedValue(codexVersion));
142
+ const distinctNames = [...new Set(matches.map((match) => match.name))];
143
+ return {
144
+ name: winner.name,
145
+ source: winner.source,
146
+ ...(version ? { version } : {}),
147
+ ...(distinctNames.length >= 2 ? { markers: distinctNames } : {}),
148
+ ...(winner.name === 'other' ? { otherValue: winner.otherValue ?? '' } : {}),
149
+ };
150
+ };
151
+ //# sourceMappingURL=agent-detection.js.map
@@ -15,6 +15,7 @@ export type ChalkInstance = ChalkInstancePrimitiveType;
15
15
  export declare const padLeft: (str: string, count: number, filler?: string) => string;
16
16
  export declare const version: string;
17
17
  export declare const USER_AGENT: string;
18
+ export declare const getRequestUserAgent: (env?: NodeJS.ProcessEnv) => string;
18
19
  export declare const NETLIFY_CYAN: ChalkInstancePrimitiveType;
19
20
  export declare const NETLIFY_CYAN_HEX = "#28b5ac";
20
21
  export declare const NETLIFYDEVLOG: string;
@@ -7,6 +7,7 @@ import { Chalk } from 'chalk';
7
7
  import WSL from 'is-wsl';
8
8
  import terminalLink from 'terminal-link';
9
9
  import { startSpinner } from '../lib/spinner.js';
10
+ import { getDrivingAgent } from './agent-detection.js';
10
11
  import getCLIPackageJson from './get-cli-package-json.js';
11
12
  import { reportError } from './telemetry/report-error.js';
12
13
  /** The parsed process argv without the binary only arguments and flags */
@@ -38,6 +39,10 @@ const arch = os.arch() === 'ia32' ? 'x86' : os.arch();
38
39
  const { name, version: packageVersion } = await getCLIPackageJson();
39
40
  export const version = packageVersion;
40
41
  export const USER_AGENT = `${name}/${version} ${platform}-${arch} node-${process.version}`;
42
+ export const getRequestUserAgent = (env = process.env) => {
43
+ const agent = getDrivingAgent(env);
44
+ return agent ? `${USER_AGENT} agent/${agent.name}` : USER_AGENT;
45
+ };
41
46
  /** A list of base command flags that needs to be sorted down on documentation and on help pages */
42
47
  const BASE_FLAGS = new Set(['--debug', '--http-proxy', '--http-proxy-certificate-filename']);
43
48
  export const NETLIFY_CYAN = chalk.rgb(40, 180, 170);
@@ -0,0 +1,2 @@
1
+ export declare const buildAuthorizeUrl: (ticketId: string, env?: NodeJS.ProcessEnv) => string;
2
+ //# sourceMappingURL=login-url.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { getDrivingAgent } from './agent-detection.js';
2
+ // By contract these two hold only a non-sensitive agent name[@version]. Every other marker's value is a flag,
3
+ // a session or run id, or a path, none of which may reach a URL.
4
+ const SOURCES_WITH_ANNOUNCED_VALUE = new Set(['NETLIFY_AGENT', 'AI_AGENT']);
5
+ const sanitizeUtmTerm = (raw) => raw.replace(/[^A-Za-z0-9_.:@-]/g, '').slice(0, 64);
6
+ const getUtmTerm = (source, env) => {
7
+ const value = SOURCES_WITH_ANNOUNCED_VALUE.has(source) ? env[source] : undefined;
8
+ return sanitizeUtmTerm(value ? `${source}:${value}` : source);
9
+ };
10
+ export const buildAuthorizeUrl = (ticketId, env = process.env) => {
11
+ const webUI = env.NETLIFY_WEB_UI || 'https://app.netlify.com';
12
+ const params = new URLSearchParams({
13
+ response_type: 'ticket',
14
+ ticket: ticketId,
15
+ utm_source: 'cli',
16
+ utm_campaign: 'integrations',
17
+ });
18
+ const agent = getDrivingAgent(env);
19
+ if (agent) {
20
+ params.set('utm_content', agent.name);
21
+ params.set('utm_term', getUtmTerm(agent.source, env));
22
+ }
23
+ return `${webUI}/authorize?${params.toString()}`;
24
+ };
25
+ //# sourceMappingURL=login-url.js.map
@@ -3,6 +3,7 @@ import process, { version as nodejsVersion } from 'process';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { getGlobalConfigStore } from '@netlify/dev-utils';
5
5
  import { isCI } from 'ci-info';
6
+ import { getDrivingAgent } from '../agent-detection.js';
6
7
  import execa from '../execa.js';
7
8
  import { isTelemetryDisabled, cliVersion } from './utils.js';
8
9
  import isValidEventName from './validation.js';
@@ -36,6 +37,18 @@ const eventConfig = {
36
37
  'user',
37
38
  ],
38
39
  };
40
+ // Every key is always present so a caller's payload can never supply its own agent attribution;
41
+ // undefined values are dropped when the event is serialized.
42
+ const getAgentProperties = () => {
43
+ const agent = getDrivingAgent();
44
+ return {
45
+ agent: agent?.name,
46
+ agent_source: agent?.source,
47
+ agent_version: agent?.version,
48
+ agent_markers: agent?.markers,
49
+ agent_other_value: agent?.otherValue,
50
+ };
51
+ };
39
52
  /**
40
53
  * Tracks a custom event with the provided payload
41
54
  */
@@ -65,7 +78,7 @@ export async function track(eventName, payload = {}) {
65
78
  anonymousId: cliId,
66
79
  duration,
67
80
  status,
68
- properties: { ...properties, nodejsVersion, cliVersion },
81
+ properties: { ...properties, nodejsVersion, cliVersion, ...getAgentProperties() },
69
82
  };
70
83
  return send('track', defaultData);
71
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "netlify-cli",
3
- "version": "27.5.1",
3
+ "version": "27.6.0",
4
4
  "description": "Netlify command line tool",
5
5
  "keywords": [
6
6
  "api",
@@ -56,9 +56,9 @@
56
56
  "@netlify/ai": "^1.0.1",
57
57
  "@netlify/api": "^15.1.2",
58
58
  "@netlify/blobs": "^11.0.3",
59
- "@netlify/build": "^36.4.7",
59
+ "@netlify/build": "^36.4.8",
60
60
  "@netlify/build-info": "^11.3.0",
61
- "@netlify/config": "^25.2.4",
61
+ "@netlify/config": "^25.2.5",
62
62
  "@netlify/dev": "^5.0.5",
63
63
  "@netlify/dev-utils": "^6.0.1",
64
64
  "@netlify/edge-bundler": "^16.0.4",
@@ -68,7 +68,7 @@
68
68
  "@netlify/images": "^2.0.1",
69
69
  "@netlify/local-functions-proxy": "^2.0.3",
70
70
  "@netlify/redirect-parser": "^16.1.1",
71
- "@netlify/zip-it-and-ship-it": "^15.5.0",
71
+ "@netlify/zip-it-and-ship-it": "^15.5.1",
72
72
  "@octokit/rest": "^22.0.0",
73
73
  "@opentelemetry/api": "^1.8.0",
74
74
  "@pnpm/tabtab": "^0.5.4",