diff --git a/docs/commands/init.md b/docs/commands/init.md index 7abf8c7b4dc..d98c89d7252 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -22,10 +22,13 @@ netlify init - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI +- `mcp-scope` (*project | user*) - Add the Netlify MCP server for this project only, or for your user across all projects - `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names +- `reset-mcp-setup` (*boolean*) - Forget your saved Netlify MCP server choices and ask which AI coding agents to set up +- `skip-mcp-setup` (*boolean*) - Skip adding the Netlify MCP server to your AI coding agents - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in -- `skip-agent-setup` (*boolean*) - Skip installing Netlify skills for AI coding agents into the project +- `skip-agent-setup` (*boolean*) - Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 39d14acfc31..072dc9e3f5c 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -1,4 +1,4 @@ -import { OptionValues } from 'commander' +import { Option, OptionValues } from 'commander' import terminalLink from 'terminal-link' import BaseCommand from '../base-command.js' @@ -11,7 +11,23 @@ export const createInitCommand = (program: BaseCommand) => ) .option('-m, --manual', 'Manually configure a git remote for CI') .option('--git-remote-name ', 'Name of Git remote to use. e.g. "origin"') - .option('--skip-agent-setup', 'Skip installing Netlify skills for AI coding agents into the project') + .option( + '--skip-agent-setup', + 'Skip installing Netlify skills and adding the Netlify MCP server for AI coding agents', + ) + .option('--skip-mcp-setup', 'Skip adding the Netlify MCP server to your AI coding agents') + .addOption( + new Option( + '--mcp-scope ', + 'Add the Netlify MCP server for this project only, or for your user across all projects', + ) + .choices(['project', 'user']) + .default('project'), + ) + .option( + '--reset-mcp-setup', + 'Forget your saved Netlify MCP server choices and ask which AI coding agents to set up', + ) .option( '--reset-context', 'Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names', @@ -26,6 +42,9 @@ For more information about getting started with Netlify CLI, see ${terminalLink( const { init } = await import('./init.js') await init(options, command, { setupAgentSkills: !options.skipAgentSetup, + setupAgentMcp: !options.skipAgentSetup && !options.skipMcpSetup, + resetMcpSetup: Boolean(options.resetMcpSetup), + mcpScope: options.mcpScope === 'user' ? 'user' : 'project', resetContext: Boolean(options.resetContext), }) }) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index f6130c62cde..c002a46dec3 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -11,6 +11,8 @@ import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' import type { LocalState, SiteInfo } from '../../utils/types.js' +import { findProjectSetupBlocker } from '../../utils/init/agent-location.js' +import { type McpScope, setupAgentMcp } from '../../utils/init/agent-mcp.js' import { setupAgentSkills } from '../../utils/init/agent-skills.js' import { getBuildSettings, saveNetlifyToml } from '../../utils/init/utils.js' import { type InitExitCode, LINKED_EXISTING_SITE_EXIT_CODE, LINKED_NEW_SITE_EXIT_CODE } from './constants.js' @@ -212,12 +214,14 @@ type InitExtraOptions = { customizeExitMessage?: InitExitMessageCustomizer | undefined exitAfterConfiguringRepo?: boolean | undefined setupAgentSkills?: boolean | undefined + setupAgentMcp?: boolean | undefined resetContext?: boolean | undefined + resetMcpSetup?: boolean | undefined + mcpScope?: McpScope | undefined } -const installAgentSkills = async (command: BaseCommand, reset: boolean): Promise => { - log() - const result = await setupAgentSkills({ workingDir: command.netlify.repositoryRoot, reset }) +const installAgentSkills = async (workingDir: string, reset: boolean): Promise => { + const result = await setupAgentSkills({ workingDir, reset }) await track('sites_agentSkillsSetup', { installed: result.installed, directories: result.directories, @@ -227,6 +231,57 @@ const installAgentSkills = async (command: BaseCommand, reset: boolean): Promise }) } +const addAgentMcp = async (command: BaseCommand, reset: boolean, scope: McpScope): Promise => { + const { repositoryRoot, state } = command.netlify + const { + selection, + agents, + failed = false, + } = await setupAgentMcp({ + workingDir: repositoryRoot, + store: state, + scope, + reset, + }) + await track('sites_agentMcpSetup', { selection, agents, failed, scope, resetMcpSetup: reset }) +} + +const setUpAgents = async ( + command: BaseCommand, + { + skills, + mcp, + reset, + resetMcp, + mcpScope, + }: { skills: boolean; mcp: boolean; reset: boolean; resetMcp: boolean; mcpScope: McpScope }, +): Promise => { + if (!skills && !mcp) { + return + } + const { repositoryRoot } = command.netlify + log() + const blocker = await findProjectSetupBlocker(repositoryRoot) + const skillsBlocked = skills && blocker !== undefined + const mcpBlocked = mcp && blocker !== undefined && (blocker.place === 'root' || mcpScope === 'project') + if (blocker && (skillsBlocked || mcpBlocked)) { + const skipped = [skillsBlocked && 'Netlify skills', mcpBlocked && 'the Netlify MCP server'] + .filter(Boolean) + .join(' and ') + log(`Skipped setting up ${skipped}: ${blocker.reason}.`) + log( + ` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} from your project folder to set up ${skipped} there.`, + ) + await track('sites_agentSetupSkipped', { skills: skillsBlocked, mcp: mcpBlocked }) + } + if (skills && !skillsBlocked) { + await installAgentSkills(repositoryRoot, reset) + } + if (mcp && !mcpBlocked) { + await addAgentMcp(command, resetMcp, mcpScope) + } +} + export const init = async ( options: OptionValues, command: BaseCommand, @@ -234,14 +289,20 @@ export const init = async ( customizeExitMessage, exitAfterConfiguringRepo = false, setupAgentSkills: shouldSetupAgentSkills = false, + setupAgentMcp: shouldSetupAgentMcp = false, resetContext = false, + resetMcpSetup = false, + mcpScope = 'project', }: InitExtraOptions = {}, ): Promise => { command.setAnalyticsPayload({ manual: options.manual, force: options.force, skipAgentSetup: options.skipAgentSetup, + skipMcpSetup: options.skipMcpSetup, resetContext: options.resetContext, + resetMcpSetup: options.resetMcpSetup, + mcpScope, }) const { repositoryRoot, state } = command.netlify @@ -253,9 +314,13 @@ export const init = async ( // Add .netlify to .gitignore file await ensureNetlifyIgnore(repositoryRoot) - if (shouldSetupAgentSkills) { - await installAgentSkills(command, resetContext) - } + await setUpAgents(command, { + skills: shouldSetupAgentSkills, + mcp: shouldSetupAgentMcp, + reset: resetContext, + resetMcp: resetMcpSetup, + mcpScope, + }) const repoUrl = getRepoUrl(existingSiteInfo) if (repoUrl && !options.force) { diff --git a/src/utils/init/agent-location.ts b/src/utils/init/agent-location.ts new file mode 100644 index 00000000000..2d04512f589 --- /dev/null +++ b/src/utils/init/agent-location.ts @@ -0,0 +1,30 @@ +import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const realpathOrSelf = async (target: string): Promise => { + try { + return await fs.realpath(target) + } catch { + return path.resolve(target) + } +} + +export interface ProjectSetupBlocker { + place: 'home' | 'root' + reason: string +} + +export const findProjectSetupBlocker = async ( + root: string, + home: string = homedir(), +): Promise => { + const resolvedRoot = await realpathOrSelf(root) + if (resolvedRoot === (await realpathOrSelf(home))) { + return { place: 'home', reason: 'this is your home directory, where they would apply to every project' } + } + if (resolvedRoot === path.parse(resolvedRoot).root) { + return { place: 'root', reason: 'this is the root of the file system' } + } + return undefined +} diff --git a/src/utils/init/agent-mcp.ts b/src/utils/init/agent-mcp.ts new file mode 100644 index 00000000000..76803744ade --- /dev/null +++ b/src/utils/init/agent-mcp.ts @@ -0,0 +1,668 @@ +import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +import { checkbox } from '@inquirer/prompts' +import { isexe } from 'isexe' + +import { type CanonicalAgentName, getDrivingAgent } from '../agent-detection.js' +import { chalk, log, netlifyCommand, warn } from '../command-helpers.js' +import execa from '../execa.js' +import { isInteractive } from '../scripted-commands.js' + +export const NETLIFY_MCP_URL = 'https://mcp.netlify.com/mcp' +export const NETLIFY_MCP_SERVER_NAME = 'netlify' +const NETLIFY_MCP_HOSTS = new Set(['mcp.netlify.com', 'netlify-mcp.netlify.app']) +const NETLIFY_MCP_PACKAGE = '@netlify/mcp' +const NATIVE_TIMEOUT_MS = 15_000 +const LIST_TIMEOUT_MS = 30_000 +const ADD_MCP_VERSION = '2.4.1' + +export const MCP_SCOPES = ['project', 'user'] as const +export type McpScope = (typeof MCP_SCOPES)[number] + +type LookupKind = 'list' | 'list-json' + +interface NativeCommands { + add: string[] + remove?: string[] + savedIn?: string +} + +interface NativeSetup { + lookup?: LookupKind + scopes: Partial> +} + +export interface McpAgent { + id: string + label: string + binary?: string + drivenBy?: CanonicalAgentName[] + projectMarkers?: string[] + projectFile?: string + inTerminal?: (env: NodeJS.ProcessEnv) => boolean + native?: NativeSetup +} + +const inEditorTerminal = (env: NodeJS.ProcessEnv): boolean => env.TERM_PROGRAM === 'vscode' + +const claudeAdd = (scope: string) => [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + scope, + NETLIFY_MCP_SERVER_NAME, + NETLIFY_MCP_URL, +] + +export const MCP_AGENTS: McpAgent[] = [ + { + id: 'claude-code', + label: 'Claude Code', + binary: 'claude', + drivenBy: ['claude'], + projectMarkers: ['.claude', '.mcp.json', 'CLAUDE.md'], + projectFile: '.mcp.json', + native: { + lookup: 'list', + scopes: { + project: { + add: claudeAdd('local'), + remove: ['mcp', 'remove', '--scope', 'local', NETLIFY_MCP_SERVER_NAME], + savedIn: "~/.claude.json, under this project's path", + }, + user: { + add: claudeAdd('user'), + remove: ['mcp', 'remove', '--scope', 'user', NETLIFY_MCP_SERVER_NAME], + savedIn: '~/.claude.json', + }, + }, + }, + }, + { + id: 'codex', + label: 'Codex', + binary: 'codex', + drivenBy: ['codex'], + projectMarkers: ['.codex'], + projectFile: path.join('.codex', 'config.toml'), + native: { + lookup: 'list-json', + scopes: { + user: { + add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL], + remove: ['mcp', 'remove', NETLIFY_MCP_SERVER_NAME], + }, + }, + }, + }, + { + id: 'opencode', + label: 'OpenCode', + binary: 'opencode', + drivenBy: ['opencode'], + projectMarkers: ['opencode.json', 'opencode.jsonc', '.opencode'], + projectFile: 'opencode.jsonc', + native: { scopes: { user: { add: ['mcp', 'add', NETLIFY_MCP_SERVER_NAME, '--url', NETLIFY_MCP_URL] } } }, + }, + { + id: 'vscode', + label: 'VS Code', + binary: 'code', + projectMarkers: [path.join('.vscode', 'mcp.json')], + projectFile: path.join('.vscode', 'mcp.json'), + inTerminal: (env) => inEditorTerminal(env) && !env.CURSOR_TRACE_ID, + native: { + scopes: { + user: { + add: ['--add-mcp', JSON.stringify({ name: NETLIFY_MCP_SERVER_NAME, type: 'http', url: NETLIFY_MCP_URL })], + }, + }, + }, + }, + { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + drivenBy: ['cursor'], + projectMarkers: ['.cursor'], + projectFile: path.join('.cursor', 'mcp.json'), + inTerminal: (env) => inEditorTerminal(env) && Boolean(env.CURSOR_TRACE_ID), + }, + { id: 'windsurf', label: 'Windsurf', binary: 'windsurf', projectMarkers: ['.windsurf'] }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + binary: 'gemini', + drivenBy: ['gemini'], + projectMarkers: ['.gemini', 'GEMINI.md'], + projectFile: path.join('.gemini', 'settings.json'), + }, + { + id: 'github-copilot-cli', + label: 'GitHub Copilot CLI', + binary: 'copilot', + drivenBy: ['copilot'], + projectFile: '.mcp.json', + }, + { + id: 'kiro-cli', + label: 'Kiro CLI', + binary: 'kiro-cli', + drivenBy: ['kiro'], + projectMarkers: ['.kiro'], + projectFile: path.join('.kiro', 'settings', 'mcp.json'), + }, + { id: 'cline', label: 'Cline', drivenBy: ['cline'], projectMarkers: ['.clinerules'] }, +] + +export type AgentMcpStatus = 'added' | 'present' | 'kept' | 'printed' | 'unsupported' | 'declined' | 'failed' +export type AgentSelection = 'driving' | 'prompt' | 'saved' | 'none' + +export interface AgentMcpSetupSummary { + scope: McpScope + selection: AgentSelection + agents: Record + failed?: boolean +} + +export interface AgentCandidate { + agent: McpAgent + detected: boolean +} + +export type ChooseAgents = (candidates: AgentCandidate[]) => Promise + +const where = (scope: McpScope): string => (scope === 'project' ? 'this project' : 'all your projects') + +const chooseWithPrompt = + (scope: McpScope): ChooseAgents => + async (candidates) => + await checkbox({ + message: `Add the Netlify MCP server for ${where(scope)} to which AI agents?`, + choices: candidates.map(({ agent, detected }) => ({ name: agent.label, value: agent, checked: detected })), + }) + +const pathExists = async (target: string): Promise => { + try { + await fs.access(target) + return true + } catch { + return false + } +} + +const findOnPath = async (binary: string, env: NodeJS.ProcessEnv): Promise => { + const pathExtensions = (env.PATHEXT ?? '').split(';').filter(Boolean) + const extensions = + process.platform === 'win32' ? (pathExtensions.length > 0 ? pathExtensions : ['.EXE', '.CMD', '.BAT']) : [''] + const isExecutableFile = (target: string) => isexe(target, { ignoreErrors: true, pathExt: extensions.join(';') }) + const candidates = (env.PATH ?? '') + .split(path.delimiter) + .filter((directory) => path.isAbsolute(directory)) + .flatMap((directory) => extensions.map((extension) => path.join(directory, binary + extension))) + const executable = await Promise.all(candidates.map(isExecutableFile)) + return candidates.find((_, index) => executable[index]) +} + +const hasProjectMarker = async (agent: McpAgent, workingDir: string): Promise => { + for (const marker of agent.projectMarkers ?? []) { + if (await pathExists(path.join(workingDir, marker))) { + return true + } + } + return false +} + +const supportsScope = (agent: McpAgent, scope: McpScope): boolean => + scope === 'user' || agent.projectFile !== undefined || agent.native?.scopes.project !== undefined + +export const findCandidates = async ( + workingDir: string, + env: NodeJS.ProcessEnv, + scope: McpScope = 'project', +): Promise => { + const candidates: AgentCandidate[] = [] + for (const agent of MCP_AGENTS.filter((candidate) => supportsScope(candidate, scope))) { + const detected = Boolean(agent.inTerminal?.(env)) || (await hasProjectMarker(agent, workingDir)) + if (detected || (agent.binary !== undefined && (await findOnPath(agent.binary, env)) !== undefined)) { + candidates.push({ agent, detected }) + } + } + return candidates.sort((a, b) => Number(b.detected) - Number(a.detected)) +} + +const parseUrl = (value: string): URL | undefined => { + try { + return new URL(value) + } catch { + return undefined + } +} + +const urlsIn = (text: string): URL[] => + (text.match(/https?:\/\/[^\s"'<>]+/g) ?? []).map(parseUrl).filter((url): url is URL => url !== undefined) + +const OUR_URL = new URL(NETLIFY_MCP_URL) + +const isOurUrl = (url: URL): boolean => + url.protocol === OUR_URL.protocol && + url.username === '' && + url.password === '' && + url.hostname.toLowerCase() === OUR_URL.hostname && + url.port === OUR_URL.port && + url.pathname.replace(/\/$/, '') === OUR_URL.pathname && + url.search === '' && + url.hash === '' + +const pointsAtNetlify = (target: string): boolean => + urlsIn(target).some((url) => NETLIFY_MCP_HOSTS.has(url.hostname.toLowerCase())) || + target.split(/\s+/).some((word) => word === NETLIFY_MCP_PACKAGE || word.startsWith(`${NETLIFY_MCP_PACKAGE}@`)) + +const isNetlifyName = (name: string): boolean => + name === NETLIFY_MCP_SERVER_NAME || name.endsWith(`:${NETLIFY_MCP_SERVER_NAME}`) + +const isControlCharacter = (character: string): boolean => { + const code = character.codePointAt(0) ?? 0 + return code < 0x20 || (code >= 0x7f && code <= 0x9f) +} + +const printable = (text: string): string => + Array.from(text) + .filter((character) => !isControlCharacter(character)) + .join('') + +const shellQuote = (arg: string): string => { + if (process.platform === 'win32') { + if (/^[\w@%+=:,./\\-]+$/.test(arg)) { + return arg + } + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"` + } + return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'` +} + +const formatCommand = (binary: string, args: string[]): string => + chalk.cyanBright.bold([binary, ...args].map(shellQuote).join(' ')) + +const addMcpArgs = (agents: McpAgent[], scope: McpScope): string[] => { + const global = scope === 'user' + return [ + '-y', + `add-mcp@${ADD_MCP_VERSION}`, + NETLIFY_MCP_URL, + '--name', + NETLIFY_MCP_SERVER_NAME, + ...(global ? ['-g'] : []), + ...(agents.length > 0 ? ['-y', ...agents.flatMap(({ id }) => ['-a', id])] : []), + ] +} + +const runNative = async (executable: string, args: string[], cwd: string, timeout = NATIVE_TIMEOUT_MS) => { + const result = await execa(executable, args, { cwd, reject: false, stdin: 'ignore', timeout }) + return { + exitCode: result.exitCode, + output: [result.stderr, result.stdout].filter(Boolean).join('\n').trim(), + stdout: result.stdout, + timedOut: result.timedOut, + } +} + +type NativeRun = Awaited> + +const describeFailure = ({ timedOut, output, exitCode }: NativeRun): string => + timedOut ? 'timed out' : output.split('\n').map(printable).join('\n') || `exit code ${String(exitCode)}` + +type Existing = { found: 'ours' | 'other'; name: string } +type Lookup = Existing | { found: 'none' } | { found: 'unknown'; run: NativeRun } + +const PENDING_APPROVAL = 'Pending approval' +const USER_SCOPE_DETAIL = /^\s*Scope: User config\b/m + +const listedServers = (stdout: string): { name: string; target: string }[] => + stdout.split('\n').flatMap((line) => { + const separator = line.indexOf(': ') + if (separator <= 0) { + return [] + } + const status = line.lastIndexOf(' - ') + const end = status > separator ? status : line.length + if (line.slice(end).includes(PENDING_APPROVAL)) { + return [] + } + return [{ name: line.slice(0, separator).trim(), target: line.slice(separator + 2, end).trim() }] + }) + +interface CodexServer { + name?: unknown + transport?: { url?: unknown; command?: unknown; args?: unknown } +} + +const codexServers = (stdout: string): { name: string; target: string }[] | undefined => { + try { + const parsed: unknown = JSON.parse(stdout) + if (!Array.isArray(parsed)) { + return undefined + } + return (parsed as CodexServer[]).map(({ name, transport }) => ({ + name: typeof name === 'string' ? name : '', + target: [ + transport?.url, + transport?.command, + ...(Array.isArray(transport?.args) ? (transport.args as unknown[]) : []), + ] + .filter((part): part is string => typeof part === 'string') + .join(' '), + })) + } catch { + return undefined + } +} + +const classify = (servers: { name: string; target: string }[]): Lookup => { + const ours = servers.find(({ target }) => urlsIn(target).some(isOurUrl)) + if (ours) { + return { found: 'ours', name: ours.name } + } + const other = servers.find(({ name, target }) => isNetlifyName(name) || pointsAtNetlify(target)) + return other ? { found: 'other', name: other.name } : { found: 'none' } +} + +const isExisting = (lookup: Lookup): lookup is Existing => lookup.found === 'ours' || lookup.found === 'other' + +const lookUpAtUserScope = async (executable: string, lookup: Lookup, workingDir: string): Promise => { + if (!isExisting(lookup) || lookup.name.startsWith('plugin:')) { + return lookup + } + const details = await runNative(executable, ['mcp', 'get', lookup.name], workingDir, LIST_TIMEOUT_MS) + if (details.exitCode !== 0) { + return { found: 'unknown', run: details } + } + return USER_SCOPE_DETAIL.test(details.stdout) ? lookup : { found: 'none' } +} + +const lookUp = async (executable: string, kind: LookupKind, workingDir: string, scope: McpScope): Promise => { + if (kind === 'list-json') { + const run = await runNative(executable, ['mcp', 'list', '--json'], scope === 'user' ? homedir() : workingDir) + const servers = run.exitCode === 0 ? codexServers(run.stdout) : undefined + return servers ? classify(servers) : { found: 'unknown', run } + } + const run = await runNative(executable, ['mcp', 'list'], workingDir, LIST_TIMEOUT_MS) + if (run.exitCode !== 0) { + return { found: 'unknown', run } + } + const lookup = classify(listedServers(run.stdout)) + return scope === 'user' ? await lookUpAtUserScope(executable, lookup, workingDir) : lookup +} + +const reportExisting = (agent: McpAgent, { found, name }: Existing): AgentMcpStatus => { + if (found === 'ours') { + log(`${agent.label} already has the Netlify MCP server.`) + return 'present' + } + log( + `${agent.label} already has a Netlify MCP server (${chalk.cyan(printable(name))}) that isn't ${chalk.underline(NETLIFY_MCP_URL)}, so another one wasn't added.`, + ) + log( + ` To switch, remove ${chalk.cyan(printable(name))} in ${agent.label} and run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)}`, + ) + return 'kept' +} + +const lookUpExisting = async ( + agent: McpAgent, + env: NodeJS.ProcessEnv, + workingDir: string, + scope: McpScope, +): Promise<{ executable?: string; lookup?: Lookup }> => { + if (agent.binary === undefined) { + return {} + } + const executable = await findOnPath(agent.binary, env) + const kind = agent.native?.lookup + if (!executable || !kind) { + return { executable } + } + return { executable, lookup: await lookUp(executable, kind, workingDir, scope) } +} + +const restartHint = (agent: McpAgent, scope: McpScope): string => + ` The Netlify MCP server loads the next time you start ${agent.label}${scope === 'project' ? ' in this folder' : ''}.` + +const commandFor = (agent: McpAgent, scope: McpScope): string => { + const native = agent.native?.scopes[scope] + if (native && agent.binary) { + return formatCommand(agent.binary, native.add) + } + return formatCommand('npx', addMcpArgs([agent], scope)) +} + +const fileNote = (agent: McpAgent, scope: McpScope): string | undefined => { + const native = agent.native?.scopes[scope] + if (native && !agent.native?.lookup) { + return ` Running it replaces any ${chalk.cyan(NETLIFY_MCP_SERVER_NAME)} server ${agent.label} already has.` + } + if (native) { + return undefined + } + if (scope === 'project') { + return ` This writes ${agent.projectFile ?? ''} in this folder. Commit it only if everyone on the project should get it.` + } + return undefined +} + +const printCommand = (agent: McpAgent, scope: McpScope, lead: string): void => { + log(`${lead} ${commandFor(agent, scope)}`) + const note = fileNote(agent, scope) + if (note) { + log(note) + } + log(restartHint(agent, scope)) +} + +const add = async ( + agent: McpAgent, + scope: McpScope, + env: NodeJS.ProcessEnv, + workingDir: string, +): Promise => { + if (!supportsScope(agent, scope)) { + log( + `${agent.label} has no per-project MCP setting, so nothing was set up. To add the Netlify MCP server for all your projects, run ${chalk.cyanBright.bold(`${netlifyCommand()} init --mcp-scope user`)}`, + ) + return 'unsupported' + } + const commands = agent.native?.scopes[scope] + const { executable, lookup } = await lookUpExisting(agent, env, workingDir, scope) + if (lookup?.found === 'unknown') { + warn(`Could not check ${agent.label} for an existing Netlify MCP server: ${describeFailure(lookup.run)}`) + printCommand(agent, scope, ` Add it yourself with`) + return 'failed' + } + if (lookup && isExisting(lookup)) { + return reportExisting(agent, lookup) + } + if (!commands || !executable || !agent.binary || !agent.native?.lookup) { + printCommand(agent, scope, `To add the Netlify MCP server to ${agent.label} for ${where(scope)}, run`) + return 'printed' + } + + const added = await runNative(executable, commands.add, workingDir) + if (added.exitCode !== 0) { + warn(`Could not add the Netlify MCP server to ${agent.label}: ${describeFailure(added)}`) + log(` Add it yourself with ${formatCommand(agent.binary, commands.add)}`) + return 'failed' + } + const savedIn = commands.savedIn ? `Saved in ${commands.savedIn}; nothing` : 'Nothing' + log(`Added the Netlify MCP server to ${agent.label} for ${where(scope)}. ${savedIn} was written to the repository.`) + if (commands.remove) { + log(` Remove it with ${formatCommand(agent.binary, commands.remove)}`) + } + log(restartHint(agent, scope)) + return 'added' +} + +export interface McpChoiceStore { + get(key: string): unknown + set(key: string, value: unknown): void + delete(key: string): void +} + +interface SavedChoice { + url: string + choice: 'accepted' | 'declined' | 'kept' +} + +const SAVED_CHOICES_KEY = 'agentMcpSetup' + +const savedChoiceKey = (agent: McpAgent, scope: McpScope): string => `${SAVED_CHOICES_KEY}.${scope}.${agent.id}` + +const savedChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope): SavedChoice | undefined => { + const saved = store.get(savedChoiceKey(agent, scope)) as SavedChoice | undefined + return saved?.url === NETLIFY_MCP_URL ? saved : undefined +} + +const saveChoice = (store: McpChoiceStore, agent: McpAgent, scope: McpScope, status: AgentMcpStatus): void => { + if (status === 'failed') { + if (store.get(savedChoiceKey(agent, scope)) !== undefined) { + store.delete(savedChoiceKey(agent, scope)) + } + return + } + const choice: SavedChoice['choice'] = status === 'declined' ? 'declined' : status === 'kept' ? 'kept' : 'accepted' + store.set(savedChoiceKey(agent, scope), { url: NETLIFY_MCP_URL, choice } satisfies SavedChoice) +} + +const saveChoices = ( + store: McpChoiceStore, + scope: McpScope, + offered: McpAgent[], + chosen: McpAgent[], + statuses: Record, +): void => { + for (const agent of offered) { + saveChoice(store, agent, scope, chosen.includes(agent) ? statuses[agent.id] : 'declined') + } +} + +const resetHint = (): string => + `Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-mcp-setup`)} to choose again.` + +type Selected = + | { selection: 'driving'; agents: [McpAgent]; declined: boolean } + | { selection: 'prompt'; agents: McpAgent[]; offered: McpAgent[] } + | { selection: 'saved'; agents: [] } + | { selection: 'none'; agents: [] } + +const selectAgents = async ({ + workingDir, + env, + interactive, + choose, + reset, + scope, + store, +}: { + workingDir: string + env: NodeJS.ProcessEnv + interactive: boolean + choose: ChooseAgents + reset: boolean + scope: McpScope + store: McpChoiceStore +}): Promise => { + const drivingAgent = getDrivingAgent(env) + const agent = drivingAgent && MCP_AGENTS.find(({ drivenBy }) => drivenBy?.includes(drivingAgent.name)) + if (agent) { + return { + selection: 'driving', + agents: [agent], + declined: !reset && savedChoice(store, agent, scope)?.choice === 'declined', + } + } + if (!interactive) { + return { selection: 'none', agents: [] } + } + const candidates = await findCandidates(workingDir, env, scope) + if (candidates.length === 0) { + return { selection: 'none', agents: [] } + } + const undecided = reset ? candidates : candidates.filter(({ agent }) => !savedChoice(store, agent, scope)) + if (undecided.length === 0) { + return { selection: 'saved', agents: [] } + } + return { selection: 'prompt', agents: await choose(undecided), offered: undecided.map(({ agent }) => agent) } +} + +export const setupAgentMcp = async ({ + workingDir, + store, + scope = 'project', + env = process.env, + interactive = isInteractive(), + choose = chooseWithPrompt(scope), + reset = false, +}: { + workingDir: string + store: McpChoiceStore + scope?: McpScope + env?: NodeJS.ProcessEnv + interactive?: boolean + choose?: ChooseAgents + reset?: boolean +}): Promise => { + const summary: AgentMcpSetupSummary = { scope, selection: 'none', agents: {} } + try { + const selected = await selectAgents({ workingDir, env, interactive, choose, reset, scope, store }) + summary.selection = selected.selection + if (selected.selection === 'saved') { + log(`Using your saved choices for the Netlify MCP server. ${resetHint()}`) + return summary + } + if (selected.selection === 'none') { + log(`To add the Netlify MCP server to your AI agents, run ${formatCommand('npx', addMcpArgs([], scope))}`) + log( + ` It asks which agents to set up. To skip the questions, add ${chalk.cyan('-y -a ')}; ${formatCommand('npx', [`add-mcp@${ADD_MCP_VERSION}`, 'list-agents'])} shows the agent names.`, + ) + return summary + } + + if (selected.selection === 'driving') { + const [agent] = selected.agents + if (selected.declined) { + log(`You chose not to add the Netlify MCP server to ${agent.label}. ${resetHint()}`) + summary.agents[agent.id] = 'declined' + } else { + const status = await add(agent, scope, env, workingDir) + summary.agents[agent.id] = status + const installed = status === 'added' || status === 'present' || status === 'kept' + if (reset && installed && savedChoice(store, agent, scope)) { + saveChoice(store, agent, scope, status) + } + } + return summary + } + + for (const agent of selected.agents) { + summary.agents[agent.id] = await add(agent, scope, env, workingDir) + } + saveChoices(store, scope, selected.offered, selected.agents, summary.agents) + const toRun = selected.agents.filter(({ id }) => summary.agents[id] === 'printed') + if (toRun.length > 0) { + log( + `Run the command shown for ${toRun.map(({ label }) => label).join(', ')} to finish. You won't be asked about ${toRun.length === 1 ? 'it' : 'them'} again in this project.`, + ) + } + log(`Your choices are saved for this project. ${resetHint()}`) + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + throw error + } + warn(`Could not set up the Netlify MCP server: ${error instanceof Error ? error.message : String(error)}`) + summary.failed = true + } + return summary +} diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 3b731b53509..60c3a532855 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { readFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import path from 'node:path' @@ -806,16 +806,52 @@ describe.concurrent('commands/init', () => { { question: 'The SSH URL of the remote git repo', answer: CONFIRM }, { question: 'Configure the following webhook for your repository', answer: CONFIRM }, ] + const writeFakeClaude = async (directory: string) => { + const dir = path.join(directory, '.fake-bin') + const calls = path.join(dir, 'calls.log') + await mkdir(dir, { recursive: true }) + await writeFile( + path.join(dir, 'claude'), + [ + '#!/bin/sh', + `echo "$*" >> "${calls}"`, + `if [ "$2" = "list" ]; then echo "No MCP servers configured."; exit 0; fi`, + `if [ "$2" = "add" ]; then exit 0; fi`, + 'exit 2', + '', + ].join('\n'), + ) + await chmod(path.join(dir, 'claude'), 0o755) + return { dir, calls } + } + const initOnLinkedSite = - ({ apiUrl, cwd, skillsHost }: { apiUrl: string; cwd: string; skillsHost: string }) => + ({ + apiUrl, + cwd, + skillsHost, + claudeBin, + home, + }: { + apiUrl: string + cwd: string + skillsHost: string + claudeBin?: string + home?: string + }) => async (...flags: string[]) => { const env = { NETLIFY_API_URL: apiUrl, NETLIFY_SITE_ID: 'site_id', NETLIFY_AUTH_TOKEN: 'fake-token', NETLIFY_SKILLS_HOST: skillsHost, + ...(claudeBin + ? { PATH: `${claudeBin}${path.delimiter}${process.env.PATH ?? ''}`, NETLIFY_AGENT: 'claude-code' } + : {}), + ...(home ? { HOME: home, USERPROFILE: home } : {}), } - const childProcess = execa(cliPath, ['init', '--manual', ...flags], { cwd, env }) + const mcpFlags = claudeBin ? [] : ['--skip-mcp-setup'] + const childProcess = execa(cliPath, ['init', '--manual', ...mcpFlags, ...flags], { cwd, env }) if (process.env.DEBUG_TESTS) { childProcess.stdout?.on('data', (data: Buffer) => { process.stderr.write(data) @@ -852,6 +888,120 @@ describe.concurrent('commands/init', () => { }) }) + test.skipIf(process.platform === 'win32')( + 'netlify init adds the Netlify MCP server for the project to the agent running it, and both skip flags silence it', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().build() + const { dir: claudeBin, calls: callsFile } = await writeFakeClaude(builder.directory) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url, claudeBin }) + + const projectScoped = await runInit() + t.expect(projectScoped.stdout).toContain('Added the Netlify MCP server to Claude Code for this project') + + const userWide = await runInit('--mcp-scope', 'user') + t.expect(userWide.stdout).toContain('Added the Netlify MCP server to Claude Code for all your projects') + + const skippedMcp = await runInit('--skip-mcp-setup') + t.expect(skippedMcp.stdout).not.toContain('MCP server') + + const skippedAll = await runInit('--skip-agent-setup') + t.expect(skippedAll.stdout).not.toContain('MCP server') + + const calls = (await readFile(callsFile, 'utf8')).trim().split('\n') + t.expect(calls).toEqual([ + 'mcp list', + 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + 'mcp list', + 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', + ]) + }) + }) + }) + }, + ) + + test.skipIf(process.platform === 'win32')( + 'netlify init skips skills and the MCP server when run from the home directory', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().ensureDirectoryExists(path.join(builder.directory, '.agents')).build() + const claudeBin = await writeFakeClaude(builder.directory) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ + apiUrl, + cwd: builder.directory, + skillsHost: skillsHost.url, + claudeBin: claudeBin.dir, + home: builder.directory, + }) + + const { stdout } = await runInit() + + t.expect(stdout).toContain('Skipped setting up Netlify skills and the Netlify MCP server') + t.expect(stdout).toContain('this is your home directory') + await t + .expect(readFile(path.join(builder.directory, '.agents', 'skills', 'netlify-functions', 'SKILL.md'))) + .rejects.toThrow(/ENOENT/) + await t.expect(readFile(claudeBin.calls, 'utf8')).rejects.toThrow(/ENOENT/) + + const userWide = await runInit('--mcp-scope', 'user') + t.expect(userWide.stdout).toContain('Skipped setting up Netlify skills: this is your home directory') + t.expect(userWide.stdout).toContain('Added the Netlify MCP server to Claude Code for all your projects') + t.expect((await readFile(claudeBin.calls, 'utf8')).trim().split('\n')).toEqual([ + 'mcp list', + 'mcp add --transport http --scope user netlify https://mcp.netlify.com/mcp', + ]) + }) + }) + }) + }, + ) + + test.skipIf(process.platform === 'win32')( + 'netlify init honors a saved no in an agent run until --reset-mcp-setup', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withGit().build() + const claudeBin = await writeFakeClaude(builder.directory) + await mkdir(path.join(builder.directory, '.netlify'), { recursive: true }) + await writeFile( + path.join(builder.directory, '.netlify', 'state.json'), + JSON.stringify({ + agentMcpSetup: { project: { 'claude-code': { url: 'https://mcp.netlify.com/mcp', choice: 'declined' } } }, + }), + ) + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost(async (skillsHost) => { + const runInit = initOnLinkedSite({ + apiUrl, + cwd: builder.directory, + skillsHost: skillsHost.url, + claudeBin: claudeBin.dir, + }) + + const declined = await runInit() + t.expect(declined.stdout).toContain('You chose not to add the Netlify MCP server to Claude Code') + await t.expect(readFile(claudeBin.calls, 'utf8')).rejects.toThrow(/ENOENT/) + + const reset = await runInit('--reset-mcp-setup') + t.expect(reset.stdout).toContain('Added the Netlify MCP server to Claude Code for this project') + t.expect((await readFile(claudeBin.calls, 'utf8')).trim().split('\n')).toEqual([ + 'mcp list', + 'mcp add --transport http --scope local netlify https://mcp.netlify.com/mcp', + ]) + }) + }) + }) + }, + ) + test('netlify init syncs installed skills: updates stale copies, removes deprecated ones, keeps edits until --reset-context', async (t) => { await withSiteBuilder(t, async (builder) => { const skillsDir = path.join('.agents', 'skills') diff --git a/tests/unit/utils/init/agent-location.test.ts b/tests/unit/utils/init/agent-location.test.ts new file mode 100644 index 00000000000..10663076b88 --- /dev/null +++ b/tests/unit/utils/init/agent-location.test.ts @@ -0,0 +1,53 @@ +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { findProjectSetupBlocker } from '../../../../src/utils/init/agent-location.js' + +describe('findProjectSetupBlocker', () => { + let root: string + let home: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'netlify-agent-location-')) + home = join(root, 'home') + await mkdir(home) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + test('blocks the home directory even when it is a git repository', async () => { + await mkdir(join(home, '.git')) + const blocker = await findProjectSetupBlocker(home, home) + expect(blocker?.place).toBe('home') + expect(blocker?.reason).toMatch(/home directory/) + }) + + test('blocks a path that resolves to the home directory through a symlink', async () => { + const link = join(root, 'link-to-home') + await symlink(home, link) + await expect(findProjectSetupBlocker(link, home)).resolves.toMatchObject({ place: 'home' }) + }) + + test('blocks the root of the file system', async () => { + const blocker = await findProjectSetupBlocker(parse(root).root, home) + expect(blocker?.place).toBe('root') + expect(blocker?.reason).toMatch(/root of the file system/) + }) + + test('allows an empty folder, where an agent starts a new site', async () => { + const empty = join(root, 'new-site') + await mkdir(empty) + await expect(findProjectSetupBlocker(empty, home)).resolves.toBeUndefined() + }) + + test('allows a project nested inside the home directory', async () => { + const project = join(home, 'code', 'site') + await mkdir(project, { recursive: true }) + await expect(findProjectSetupBlocker(project, home)).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/utils/init/agent-mcp.test.ts b/tests/unit/utils/init/agent-mcp.test.ts new file mode 100644 index 00000000000..5aa8be4fb62 --- /dev/null +++ b/tests/unit/utils/init/agent-mcp.test.ts @@ -0,0 +1,803 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { delimiter, join, relative, resolve } from 'node:path' + +import { LocalState } from '@netlify/dev-utils' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { log, warn } from '../../../../src/utils/command-helpers.js' +import execa from '../../../../src/utils/execa.js' +import { + type ChooseAgents, + type McpChoiceStore, + type McpScope, + NETLIFY_MCP_URL, + findCandidates, + setupAgentMcp, +} from '../../../../src/utils/init/agent-mcp.js' + +vi.mock('../../../../src/utils/command-helpers.js', async (importOriginal) => ({ + ...(await importOriginal()), + log: vi.fn(), + warn: vi.fn(), +})) + +vi.mock('../../../../src/utils/execa.js', () => ({ default: vi.fn() })) + +const execaMock = vi.mocked(execa) as unknown as ReturnType + +const exited = (exitCode: number, stderr = '', stdout = '') => ({ exitCode, stdout, stderr, timedOut: false }) +const listed = (...lines: string[]) => exited(0, '', ['Checking MCP server health…', '', ...lines].join('\n')) +const timedOut = () => ({ exitCode: undefined, stdout: '', stderr: '', timedOut: true }) +const codexListed = (...servers: { name: string; transport: Record }[]) => + exited(0, '', JSON.stringify(servers)) + +const OURS_LINE = `netlify: ${NETLIFY_MCP_URL} (HTTP) - ✔ Connected` +const LOCAL_LINE = 'netlify: npx -y @netlify/mcp - ✔ Connected' +const LEGACY_PLUGIN_LINE = 'plugin:netlify:netlify: https://netlify-mcp.netlify.app/mcp (HTTP) - ✔ Connected' +const UNRELATED_LINE = 'netliray: https://netliray.netlify.app/mcp (HTTP) - ✘ Failed to connect' + +const claudeAdd = (scope: string) => ['mcp', 'add', '--transport', 'http', '--scope', scope, 'netlify', NETLIFY_MCP_URL] +const ADD_MCP = `npx -y add-mcp@2.4.1 ${NETLIFY_MCP_URL} --name netlify` + +const logged = () => + vi + .mocked(log) + .mock.calls.map(([message]) => message) + .join('\n') +const warned = () => + vi + .mocked(warn) + .mock.calls.map(([message]) => message) + .join('\n') + +const neverChoose = vi.fn(() => Promise.reject(new Error('prompt should not open'))) + +const executableName = (binary: string) => (process.platform === 'win32' ? `${binary}.CMD` : binary) + +const binUnderCwd = async () => { + const parent = resolve('tmp') + await mkdir(parent, { recursive: true }) + return await mkdtemp(join(parent, 'netlify-agent-mcp-bin-')) +} + +describe('setupAgentMcp', () => { + let root: string + let project: string + let bin: string + let saved: Map + let store: McpChoiceStore + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'netlify-agent-mcp-')) + project = join(root, 'project') + bin = join(root, 'bin') + await mkdir(project) + await mkdir(bin) + saved = new Map() + store = { + get: (key) => saved.get(key), + set: (key, value) => saved.set(key, value), + delete: (key) => saved.delete(key), + } + }) + + afterEach(async () => { + vi.clearAllMocks() + execaMock.mockReset() + await rm(root, { recursive: true, force: true }) + }) + + const install = async (...binaries: string[]) => { + for (const binary of binaries) { + const file = join(bin, executableName(binary)) + await writeFile(file, '') + await chmod(file, 0o755) + } + } + + const installed = (binary: string) => join(bin, executableName(binary)) + + const run = ( + env: NodeJS.ProcessEnv, + options: { + interactive?: boolean + choose?: ChooseAgents + reset?: boolean + path?: string + scope?: McpScope + store?: McpChoiceStore + } = {}, + ) => + setupAgentMcp({ + workingDir: project, + store: options.store ?? store, + scope: options.scope ?? 'project', + env: { PATH: options.path ?? bin, ...env }, + interactive: options.interactive ?? false, + choose: options.choose ?? neverChoose, + reset: options.reset ?? false, + }) + + const chooseIds = (...ids: string[]) => + vi.fn((candidates) => + Promise.resolve(candidates.map(({ agent }) => agent).filter(({ id }) => ids.includes(id))), + ) + + const offeredIds = (choose: ReturnType, call = 0) => + choose.mock.calls[call][0].map(({ agent }) => agent.id) + + describe('when an agent runs init', () => { + test('installs Claude for this project only, without a prompt, after checking every scope', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)).mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { interactive: true })).resolves.toEqual({ + scope: 'project', + selection: 'driving', + agents: { 'claude-code': 'added' }, + }) + + expect(neverChoose).not.toHaveBeenCalled() + expect(execaMock).toHaveBeenNthCalledWith( + 1, + installed('claude'), + ['mcp', 'list'], + expect.objectContaining({ cwd: project, reject: false, stdin: 'ignore' }), + ) + expect(execaMock).toHaveBeenNthCalledWith( + 2, + installed('claude'), + claudeAdd('local'), + expect.objectContaining({ cwd: project }), + ) + expect(logged()).toContain("Saved in ~/.claude.json, under this project's path") + expect(logged()).toContain('claude mcp remove --scope local netlify') + expect(logged()).toContain('loads the next time you start Claude Code in this folder') + }) + + test('does not install when the existing-server check fails', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('installs user-wide only when --mcp-scope user is passed', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' }) + + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('user'), expect.anything()) + }) + + test('prints a project add-mcp command for Codex, naming the file it writes', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(codexListed()) + + await expect(run({ CODEX_CI: '1' })).resolves.toMatchObject({ agents: { codex: 'printed' } }) + + expect(execaMock).toHaveBeenCalledWith(installed('codex'), ['mcp', 'list', '--json'], expect.anything()) + expect(logged()).toContain(`${ADD_MCP} -y -a codex`) + expect(logged()).not.toContain(' -g') + expect(logged()).toContain(join('.codex', 'config.toml')) + }) + + test('runs the Codex CLI when the scope is user', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(codexListed()).mockResolvedValueOnce(exited(0)) + + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'added' } }) + + expect(execaMock).toHaveBeenNthCalledWith( + 1, + installed('codex'), + ['mcp', 'list', '--json'], + expect.objectContaining({ cwd: homedir() }), + ) + expect(execaMock).toHaveBeenNthCalledWith( + 2, + installed('codex'), + ['mcp', 'add', 'netlify', '--url', NETLIFY_MCP_URL], + expect.anything(), + ) + expect(logged()).toContain('loads the next time you start Codex.') + }) + + test.each([ + ['the hosted URL under another name', { type: 'streamable_http', url: NETLIFY_MCP_URL }, 'present'], + [ + 'the local npx server under another name', + { type: 'stdio', command: 'npx', args: ['-y', '@netlify/mcp'] }, + 'kept', + ], + ])('finds a Codex server with %s', async (_, transport, status) => { + await install('codex') + execaMock.mockResolvedValueOnce(codexListed({ name: 'netlify-prod', transport })) + + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: status } }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('treats unreadable Codex output as a failed check and adds nothing', async () => { + await install('codex') + execaMock.mockResolvedValueOnce(exited(0, '', 'not json')) + + await expect(run({ CODEX_CI: '1' }, { scope: 'user' })).resolves.toMatchObject({ agents: { codex: 'failed' } }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('says nothing needs doing when the hosted server is already set up at any scope', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE, OURS_LINE)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'present' }, + }) + + expect(logged()).toContain('already has the Netlify MCP server') + expect(logged()).not.toContain('mcp add') + }) + + test.each([ + ['a local npx server', LOCAL_LINE, 'netlify'], + ['a plugin server on the old host', LEGACY_PLUGIN_LINE, 'plugin:netlify:netlify'], + ])('leaves %s alone instead of adding a second one', async (_, line, name) => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(line)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(logged()).toContain(name) + expect(logged()).toContain('init --reset-mcp-setup') + expect(logged()).not.toContain('mcp add') + }) + + test('prints an existing server name without its control characters', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed('\u001B[2J\u0007evil:netlify: npx -y @netlify/mcp - ✔ Connected')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(logged()).toContain('[2Jevil:netlify') + expect(logged()).not.toContain('\u001B[2J') + expect(logged()).not.toContain('\u0007') + }) + + test('adds the server when the only Netlify entry is a .mcp.json server still pending approval', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(`netlify: ${NETLIFY_MCP_URL} (HTTP) - ⏸ Pending approval`)) + .mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + }) + + test.each([ + ['another port', 'https://mcp.netlify.com:8443/mcp'], + ['another scheme', 'http://mcp.netlify.com/mcp'], + ['another path', 'https://mcp.netlify.com/mcp/v2'], + ['a query', 'https://mcp.netlify.com/mcp?token=x'], + ['a fragment', 'https://mcp.netlify.com/mcp#x'], + ['credentials', 'https://user:secret@mcp.netlify.com/mcp'], + ])('treats the hosted host with %s as another Netlify server', async (_, url) => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(`netlify: ${url} (HTTP) - ✔ Connected`)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + }) + + test('adds user-wide when the only Netlify entry is set for this project', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(OURS_LINE)) + .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: Local config (private to you in this project)\n')) + .mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), ['mcp', 'get', 'netlify'], expect.anything()) + expect(execaMock).toHaveBeenNthCalledWith(3, installed('claude'), claudeAdd('user'), expect.anything()) + }) + + test('leaves a user-wide entry alone when the scope is user', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(OURS_LINE)) + .mockResolvedValueOnce(exited(0, '', 'netlify:\n Scope: User config (available in all your projects)\n')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'present' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(2) + }) + + test('adds nothing user-wide when the scope of an existing entry cannot be read', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(OURS_LINE)).mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { scope: 'user' })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(2) + }) + + test('reads older claude mcp list output that has no health status', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(`netlify: ${NETLIFY_MCP_URL} (HTTP)`)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'present' }, + }) + }) + + test('does not treat a URL that only contains the hosted URL as the hosted server', async () => { + await install('claude') + execaMock + .mockResolvedValueOnce(listed(`evil: https://evil.example/${NETLIFY_MCP_URL} (HTTP) - ✔ Connected`)) + .mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + }) + + test('prints add-mcp for Gemini CLI without running anything', async () => { + await expect(run({ GEMINI_CLI: '1' })).resolves.toMatchObject({ agents: { 'gemini-cli': 'printed' } }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`${ADD_MCP} -y -a gemini-cli`) + }) + + test('suggests nothing user-wide at project scope for an agent with no per-project setting', async () => { + await expect(run({ CLINE_ACTIVE: 'true' })).resolves.toMatchObject({ agents: { cline: 'unsupported' } }) + + expect(logged()).toContain('has no per-project MCP setting') + expect(logged()).toContain('init --mcp-scope user') + expect(logged()).not.toContain(' -g') + }) + + test('suggests the user-wide command for that agent when the scope is user', async () => { + await run({ CLINE_ACTIVE: 'true' }, { scope: 'user' }) + + expect(logged()).toContain(`${ADD_MCP} -g -y -a cline`) + }) + + test('prints the command without a lookup when the agent CLI is not on PATH', async () => { + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) + }) + + describe('with a PATH entry relative to the working directory', () => { + let cwdBin: string + + beforeEach(async () => { + cwdBin = await binUnderCwd() + const file = join(cwdBin, executableName('claude')) + await writeFile(file, '') + await chmod(file, 0o755) + }) + + afterEach(async () => { + await rm(cwdBin, { recursive: true, force: true }) + }) + + test('never runs an agent binary found through it', async () => { + const relativeBin = relative(resolve(), cwdBin) + expect(relativeBin).not.toBe(cwdBin) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { path: relativeBin })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('runs the agent binary from the same directory once it is absolute', async () => { + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { path: [relative(resolve(), cwdBin), cwdBin].join(delimiter) }) + + expect(execaMock).toHaveBeenNthCalledWith( + 1, + join(cwdBin, executableName('claude')), + ['mcp', 'list'], + expect.anything(), + ) + }) + }) + + test.skipIf(process.platform === 'win32')('never runs an agent binary that is not executable', async () => { + await writeFile(installed('claude'), '') + await chmod(installed('claude'), 0o644) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('never runs a directory that has the agent binary name', async () => { + await mkdir(installed('claude')) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'printed' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('respects a saved no from the prompt for this project', async () => { + await install('claude') + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + + await expect(run({ NETLIFY_AGENT: 'claude-code' })).resolves.toMatchObject({ + agents: { 'claude-code': 'declined' }, + }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain('init --reset-mcp-setup') + }) + + test('--reset-mcp-setup replaces a saved no once the agent running init has the server', async () => { + await install('claude') + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await expect(run({ NETLIFY_AGENT: 'claude-code' }, { reset: true })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual({ url: NETLIFY_MCP_URL, choice: 'accepted' }) + }) + + test('--reset-mcp-setup keeps a saved no when the add fails for the agent running init', async () => { + await install('claude') + const declined = { url: NETLIFY_MCP_URL, choice: 'declined' } + saved.set('agentMcpSetup.project.claude-code', declined) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true }) + + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual(declined) + }) + + test('prints the generic command with how to skip its questions for an agent it has no mapping for', async () => { + await expect(run({ AGENT: 'amp' })).resolves.toMatchObject({ selection: 'none' }) + + expect(neverChoose).not.toHaveBeenCalled() + expect(logged()).toContain(ADD_MCP) + expect(logged()).not.toContain(`${ADD_MCP} -`) + expect(logged()).toContain('-y -a ') + expect(logged()).toContain('add-mcp@2.4.1 list-agents') + }) + + test('shows the checkbox to a person in a terminal when the detected agent has no mapping', async () => { + await install('claude') + const choose = chooseIds() + + await expect(run({ AGENT: 'amp' }, { interactive: true, choose })).resolves.toMatchObject({ + selection: 'prompt', + }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + }) + + describe('when a person runs init', () => { + test('offers detected agents preticked and installed ones unticked', async () => { + await mkdir(join(project, '.cursor')) + await install('code', 'codex') + const choose = chooseIds() + + await run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose }) + + expect(choose.mock.calls[0][0].map(({ agent, detected }) => [agent.id, detected])).toEqual([ + ['vscode', true], + ['cursor', true], + ['codex', false], + ]) + }) + + test('adds Claude for this project only after checking it has no Netlify server yet', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(UNRELATED_LINE)).mockResolvedValueOnce(exited(0)) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'added' }, + }) + + expect(execaMock).toHaveBeenNthCalledWith(2, installed('claude'), claudeAdd('local'), expect.anything()) + expect(logged()).toContain('for this project') + expect(logged()).toContain('claude mcp remove --scope local netlify') + }) + + test('prints project commands for ticked agents whose CLIs only add user-wide', async () => { + await install('code') + + await expect( + run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose: chooseIds('vscode') }), + ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) + + expect(execaMock).not.toHaveBeenCalled() + expect(logged()).toContain(`${ADD_MCP} -y -a vscode`) + expect(logged()).toContain(join('.vscode', 'mcp.json')) + expect(logged()).toContain('Run the command shown for VS Code to finish') + }) + + test('prints instead of running an agent CLI that cannot check for an existing server', async () => { + await install('code') + + await expect( + run({ TERM_PROGRAM: 'vscode' }, { interactive: true, choose: chooseIds('vscode'), scope: 'user' }), + ).resolves.toMatchObject({ agents: { vscode: 'printed' } }) + + expect(execaMock).not.toHaveBeenCalled() + const config = JSON.stringify({ name: 'netlify', type: 'http', url: NETLIFY_MCP_URL }) + const quoted = process.platform === 'win32' ? `"${config.replace(/"/g, '\\"')}"` : `'${config}'` + expect(logged()).toContain(`code --add-mcp ${quoted}`) + expect(logged()).toContain('Running it replaces any') + }) + + test('leaves agents with no per-project setting out of the project-scope prompt', async () => { + await mkdir(join(project, '.windsurf')) + await install('windsurf', 'claude') + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + + test('leaves a ticked agent alone when it already has another Netlify server', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(LOCAL_LINE)) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'kept' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + }) + + test('does not add when the lookup fails', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(exited(1, 'boom')) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(execaMock).toHaveBeenCalledTimes(1) + expect(warned()).toContain('boom') + }) + + test('does not add when the lookup times out', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(timedOut()) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(warned()).toContain('timed out') + }) + + test('reports a failed add with the command to run by hand', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'nope')) + + await expect(run({}, { interactive: true, choose: chooseIds('claude-code') })).resolves.toMatchObject({ + agents: { 'claude-code': 'failed' }, + }) + + expect(warned()).toContain('nope') + expect(logged()).toContain(`claude ${claudeAdd('local').join(' ')}`) + }) + + test('adds nothing when the person unticks every agent', async () => { + await install('claude') + + await expect(run({}, { interactive: true, choose: chooseIds() })).resolves.toMatchObject({ + selection: 'prompt', + agents: {}, + }) + + expect(execaMock).not.toHaveBeenCalled() + }) + + test('rethrows a cancelled prompt so init stops like any other prompt', async () => { + await install('claude') + const cancelled = Object.assign(new Error('User force closed the prompt'), { name: 'ExitPromptError' }) + + await expect(run({}, { interactive: true, choose: vi.fn(() => Promise.reject(cancelled)) })).rejects.toBe( + cancelled, + ) + }) + + test('prints the generic command without a prompt when no terminal is attached', async () => { + await install('claude') + + await expect(run({})).resolves.toMatchObject({ selection: 'none', agents: {} }) + + expect(neverChoose).not.toHaveBeenCalled() + expect(execaMock).not.toHaveBeenCalled() + }) + }) + + describe('saved choices', () => { + test('remembers both a yes and a no for this project, so the next run does not prompt', async () => { + await install('claude', 'codex') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + const choose = chooseIds('claude-code') + + await run({}, { interactive: true, choose }) + expect(offeredIds(choose)).toEqual(['claude-code', 'codex']) + expect(saved.get('agentMcpSetup.project.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) + expect(neverChoose).not.toHaveBeenCalled() + expect(logged()).toContain('Using your saved choices') + }) + + test('a choice saved for project scope does not answer a user-scope run', async () => { + await install('codex') + saved.set('agentMcpSetup.project.codex', { url: NETLIFY_MCP_URL, choice: 'declined' }) + const choose = chooseIds() + + await run({}, { interactive: true, choose, scope: 'user' }) + + expect(offeredIds(choose)).toEqual(['codex']) + }) + + test('a user-scope run keeps the project answer, so the next default run does not ask again', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + await run({}, { interactive: true, choose: chooseIds(), scope: 'user' }) + + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) + expect(saved.get('agentMcpSetup.project.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + expect(saved.get('agentMcpSetup.user.codex')).toEqual({ url: NETLIFY_MCP_URL, choice: 'declined' }) + }) + + test('asks only about an agent installed since the last run', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + await install('opencode') + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['opencode']) + }) + + test('--reset-mcp-setup asks about every agent again', async () => { + await install('codex') + await run({}, { interactive: true, choose: chooseIds() }) + const choose = chooseIds() + + await run({}, { interactive: true, choose, reset: true }) + + expect(offeredIds(choose)).toEqual(['codex']) + }) + + test('remembers a kept server, so a rerun does not offer the agent again', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed(LOCAL_LINE)) + await run({}, { interactive: true, choose: chooseIds('claude-code') }) + + await expect(run({}, { interactive: true })).resolves.toMatchObject({ selection: 'saved' }) + expect(saved.get('agentMcpSetup.project.claude-code')).toEqual({ url: NETLIFY_MCP_URL, choice: 'kept' }) + }) + + test('does not save a yes whose add failed, so it is offered again', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + await run({}, { interactive: true, choose: chooseIds('claude-code') }) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + + test('forgets a saved no when the add fails after --reset-mcp-setup, so it is offered again', async () => { + await install('claude') + saved.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + await run({}, { interactive: true, choose: chooseIds('claude-code'), reset: true }) + expect(saved.has('agentMcpSetup.project.claude-code')).toBe(false) + const choose = chooseIds() + + await run({}, { interactive: true, choose }) + + expect(offeredIds(choose)).toEqual(['claude-code']) + }) + + test('an agent-driven run does not save anything', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }) + + expect(saved.size).toBe(0) + }) + }) + + describe('with the real project state file', () => { + const stateFile = () => join(project, '.netlify', 'state.json') + + test('saves a choice nested under agentMcpSetup and reads it back on the next run', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({}, { interactive: true, choose: chooseIds('claude-code'), store: new LocalState(project) }) + + const written = JSON.parse(await readFile(stateFile(), 'utf8')) as Record + expect(written).toEqual({ + agentMcpSetup: { project: { 'claude-code': { url: NETLIFY_MCP_URL, choice: 'accepted' } } }, + }) + await expect(run({}, { interactive: true, store: new LocalState(project) })).resolves.toMatchObject({ + selection: 'saved', + }) + }) + + test('an agent run with --reset-mcp-setup and nothing saved never creates the state file', async () => { + await install('claude') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(0)) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) + + await expect(readFile(stateFile(), 'utf8')).rejects.toThrow(/ENOENT/) + }) + + test('an agent run with --reset-mcp-setup leaves the state file byte for byte when the add fails', async () => { + await install('claude') + const state = new LocalState(project) + state.set('siteId', 'site_id') + state.set('agentMcpSetup.project.claude-code', { url: NETLIFY_MCP_URL, choice: 'declined' }) + const before = await readFile(stateFile(), 'utf8') + execaMock.mockResolvedValueOnce(listed()).mockResolvedValueOnce(exited(1, 'boom')) + + await run({ NETLIFY_AGENT: 'claude-code' }, { reset: true, store: new LocalState(project) }) + + await expect(readFile(stateFile(), 'utf8')).resolves.toBe(before) + }) + }) + + describe('findCandidates', () => { + test('tells Cursor terminals apart from VS Code ones', async () => { + const candidates = await findCandidates(project, { PATH: bin, TERM_PROGRAM: 'vscode', CURSOR_TRACE_ID: 'x' }) + + expect(candidates.map(({ agent }) => agent.id)).toEqual(['cursor']) + }) + + test('finds nothing in a plain terminal with no agent folders or CLIs', async () => { + await expect(findCandidates(project, { PATH: bin })).resolves.toEqual([]) + }) + }) +})