diff --git a/src/commands/init/ai-rules.ts b/src/commands/init/ai-rules.ts new file mode 100644 index 00000000000..892da768cc2 --- /dev/null +++ b/src/commands/init/ai-rules.ts @@ -0,0 +1,246 @@ +import { resolve } from 'node:path' +import { promises as fs } from 'node:fs' +import type { NetlifyAPI } from '@netlify/api' + +import { + chalk, + log, + logPadded, + logAndThrowError, + type APIError, + NETLIFY_CYAN, + NETLIFYDEVLOG, + NETLIFYDEVWARN, + NETLIFYDEVERR, +} from '../../utils/command-helpers.js' +import { normalizeRepoUrl } from '../../utils/normalize-repo-url.js' +import { runGit } from '../../utils/run-git.js' +import { startSpinner } from '../../lib/spinner.js' +import { detectIDE } from '../../recipes/ai-context/index.js' +import { type ConsumerConfig } from '../../recipes/ai-context/context.js' +import { + generateMcpConfig, + configureMcpForVSCode, + configureMcpForCursor, + configureMcpForWindsurf, + showGenericMcpConfig, +} from '../../utils/mcp-utils.js' +import type BaseCommand from '../base-command.js' +import type { SiteInfo } from '../../utils/types.js' +import inquirer from 'inquirer' + +const SPARK_URL = process.env.SPARK_URL ?? 'https://spark.netlify.app' +const AI_SITE_PROMPT_GEN_URL = `${SPARK_URL}/site-prompt-gen` +const DOCS_URL = process.env.DOCS_URL ?? 'https://docs.netlify.com' + +/** + * Project information interface for AI projects + */ +interface ProjectInfo { + success: boolean + projectId: string + prompt: string +} + +// Trigger IDE-specific MCP configuration +const triggerMcpConfiguration = async (ide: ConsumerConfig, projectPath: string): Promise => { + log(`\n${chalk.blue('šŸ”§ MCP Configuration for')} ${NETLIFY_CYAN(ide.presentedName)}`) + + const { shouldConfigure } = await inquirer.prompt<{ shouldConfigure: boolean }>([ + { + type: 'confirm', + name: 'shouldConfigure', + message: `Would you like to automatically configure the MCP server for ${ide.presentedName}?`, + default: true, + }, + ]) + + if (!shouldConfigure) { + log(` ${chalk.dim('You can configure MCP manually later for enhanced AI capabilities:')}`) + log( + ` ${chalk.dim('Documentation:')} ${NETLIFY_CYAN( + 'https://docs.netlify.com/welcome/build-with-ai/netlify-mcp-server/', + )}`, + ) + + return false + } + + try { + const config = generateMcpConfig(ide) + + // IDE-specific configuration actions + switch (ide.key) { + case 'vscode': + await configureMcpForVSCode(config, projectPath) + break + case 'cursor': + await configureMcpForCursor(config, projectPath) + break + case 'windsurf': + await configureMcpForWindsurf(config, projectPath) + break + default: + showGenericMcpConfig(config, ide.presentedName) + } + + log(`${NETLIFYDEVLOG} MCP configuration completed for ${NETLIFY_CYAN(ide.presentedName)}`) + return true + } catch (error) { + log(`${NETLIFYDEVERR} Failed to configure MCP: ${error instanceof Error ? error.message : 'Unknown error'}`) + return false + } +} + +const fetchProjectInfo = async (url: string): Promise => { + try { + const response = await fetch(url, { + headers: { + 'content-type': 'text/plain', + }, + }) + + const data = (await response.text()) as unknown as string + const parsedData = JSON.parse(data) as unknown as ProjectInfo + return parsedData + } catch (error) { + throw new Error(`Failed to fetch project information: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +} + +const getRepoUrlFromProjectId = async (api: NetlifyAPI, projectId: string): Promise => { + try { + const siteInfo = (await api.getSite({ siteId: projectId })) as SiteInfo + const repoUrl = siteInfo.build_settings?.repo_url + + if (!repoUrl) { + throw new Error(`No repository URL found for project ID: ${projectId}`) + } + + return repoUrl + } catch (error) { + if ((error as APIError).status === 404) { + throw new Error(`Project with ID ${projectId} not found`) + } + throw new Error(`Failed to fetch project data: ${(error as Error).message}`) + } +} + +const savePrompt = async (instructions: string, ntlContext: string | null, targetDir: string): Promise => { + try { + const filePath = resolve(targetDir, 'AI-instructions.md') + await fs.writeFile(filePath, `Context: ${ntlContext ?? ''}\n\n${instructions}`, 'utf-8') + log(`${NETLIFYDEVLOG} AI instructions saved to ${NETLIFY_CYAN('AI-instructions.md')}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + log(`${NETLIFYDEVWARN} Warning: Failed to save AI instructions: ${errorMessage}`) + } +} + +const cloneRepo = async (repoUrl: string, targetDir: string, debug: boolean): Promise => { + try { + await runGit(['clone', repoUrl, targetDir], !debug) + } catch (error) { + throw new Error(`Failed to clone repository: ${error instanceof Error ? error.message : error?.toString() ?? ''}`) + } +} + +/** + * Handles AI rules initialization workflow + * This is the experimental --ai-rules functionality for the init command + */ +export const initWithAiRules = async (hash: string, command: BaseCommand): Promise => { + // Authenticate first before any API operations + await command.authenticate() + const { api } = command.netlify + + log(`${NETLIFY_CYAN('šŸ¤– Initializing AI project')} with rules...`) + log(`${NETLIFY_CYAN('User:')} ${api.accessToken ? 'Authenticated āœ…' : 'Not authenticated āŒ'}`) + + try { + // Step 1: Decode hash and fetch project information + log('\nšŸ“‹ Extracting project details...') + const decodedUrl = `${AI_SITE_PROMPT_GEN_URL}/${hash}` + log(`${NETLIFY_CYAN('Decoded URL:')} ${decodedUrl}`) + + log('\nšŸ” Fetching project information...') + const projectInfo = await fetchProjectInfo(decodedUrl) + + // Step 2: Get repository URL from project ID via Netlify site API + log('\nšŸ”— Linking to Netlify project and fetching repository...') + const repositoryUrl = await getRepoUrlFromProjectId(api, projectInfo.projectId) + + // Step 3: Clone repository + const { repoUrl, repoName } = normalizeRepoUrl(repositoryUrl) + const targetDir = `ai-project-${repoName}-${hash.substring(0, 8)}` + + const cloneSpinner = startSpinner({ text: `Cloning repository to ${NETLIFY_CYAN(targetDir)}` }) + + await cloneRepo(repoUrl, targetDir, false) + cloneSpinner.success({ text: `Cloned repository to ${NETLIFY_CYAN(targetDir)}` }) + + // Step 4: Save AI instructions to file + if (projectInfo.prompt) { + const ntlContext = await fetch(`${DOCS_URL}/ai-context/scoped-context?scopes=serverless,blobs,forms`) + .then((res) => res.text()) + .catch(() => { + return null + }) + log('\nšŸ“ Saving AI instructions...') + await savePrompt(projectInfo.prompt, ntlContext, targetDir) + } + + // Step 5: Detect IDE and configure MCP server + log('\nšŸ” Detecting development environment...') + const detectedIDE = await detectIDE() + let mcpConfigured = false + + if (detectedIDE) { + log(`${NETLIFYDEVLOG} Detected development environment: ${NETLIFY_CYAN(detectedIDE.presentedName)}`) + mcpConfigured = await triggerMcpConfiguration(detectedIDE, targetDir) + } + + // Update working directory to cloned repo + process.chdir(targetDir) + command.workingDir = targetDir + + // Success message with next steps + log() + log(`${NETLIFYDEVLOG} Your AI project is ready to go!`) + log(`→ Project cloned to: ${NETLIFY_CYAN(targetDir)}`) + if (projectInfo.prompt) { + log(`→ AI instructions saved: ${NETLIFY_CYAN('AI-instructions.md')}`) + } + log() + log(`${NETLIFYDEVWARN} Step 1: Enter your project directory`) + log(` ${NETLIFY_CYAN(`cd ${targetDir}`)}`) + + if (detectedIDE) { + if (mcpConfigured) { + log(`${NETLIFYDEVWARN} Step 2: MCP Server Configured`) + log(` ${NETLIFYDEVLOG} ${NETLIFY_CYAN(detectedIDE.key)} is ready with Netlify MCP server`) + log(` ${chalk.dim('šŸ’” MCP will activate when you reload/restart your development environment')}`) + } else { + log(`${NETLIFYDEVWARN} Step 2: Manual MCP Configuration`) + log(` ${NETLIFY_CYAN(detectedIDE.key)} detected - MCP setup was skipped`) + log(` ${chalk.dim('You can configure MCP manually later for enhanced AI capabilities:')}`) + log( + ` ${chalk.dim('Documentation:')} ${NETLIFY_CYAN( + `${DOCS_URL}/welcome/build-with-ai/netlify-mcp-server/`, + )}`, + ) + } + log() + } + + if (projectInfo.prompt) { + const stepNumber = detectedIDE ? '3' : '2' + log(`${NETLIFYDEVWARN} Step ${stepNumber}: Ask your AI assistant to process the instructions`) + log() + logPadded(NETLIFY_CYAN(`Follow ${targetDir}/AI-instructions.md and create a new site`)) + log() + } + } catch (error) { + return logAndThrowError(error) + } +} diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index ff9552ac350..e959cc5745f 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -1,4 +1,4 @@ -import { OptionValues } from 'commander' +import { OptionValues, Option } from 'commander' import terminalLink from 'terminal-link' import BaseCommand from '../base-command.js' @@ -11,6 +11,7 @@ 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"') + .addOption(new Option('--ai-rules ', 'Initialize with AI project configuration (experimental)').hideHelp()) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/cli/get-started/' return ` @@ -18,6 +19,14 @@ For more information about getting started with Netlify CLI, see ${terminalLink( ` }) .action(async (options: OptionValues, command: BaseCommand) => { + // Check for experimental AI rules flag + if (options.aiRules) { + const { initWithAiRules } = await import('./ai-rules.js') + await initWithAiRules(options.aiRules as string, command) + return + } + + // Standard init flow const { init } = await import('./init.js') await init(options, command) }) diff --git a/src/lib/functions/server.ts b/src/lib/functions/server.ts index 23dac7cad6c..85590f38f2e 100644 --- a/src/lib/functions/server.ts +++ b/src/lib/functions/server.ts @@ -169,7 +169,8 @@ export const createHandler = function (options: GetFunctionsServerOptions): Requ const rawQuery = new URL(request.originalUrl, 'http://example.com').search.slice(1) // TODO(serhalp): Update several tests to pass realistic `config` objects and remove nullish coalescing. const protocol = options.config?.dev?.https ? 'https' : 'http' - const url = new URL(requestPath, `${protocol}://${request.get('host') || 'localhost'}`) + const hostname = request.get('host') || 'localhost' + const url = new URL(requestPath, `${protocol}://${hostname}`) url.search = rawQuery const rawUrl = url.toString() const event = { diff --git a/src/recipes/ai-context/index.ts b/src/recipes/ai-context/index.ts index 8063062075d..376143fef24 100644 --- a/src/recipes/ai-context/index.ts +++ b/src/recipes/ai-context/index.ts @@ -77,7 +77,7 @@ const promptForContextConsumerSelection = async (): Promise => { * Checks if a command belongs to a known IDEs by checking if it includes a specific string. * For example, the command that starts windsurf looks something like "/applications/windsurf.app/contents/...". */ -const getConsumerKeyFromCommand = (command: string): string | null => { +export const getConsumerKeyFromCommand = (command: string): string | null => { // The actual command is something like "/applications/windsurf.app/contents/...", but we are only looking for windsurf const match = cliContextConsumers.find( (consumer) => consumer.consumerProcessCmd && command.includes(consumer.consumerProcessCmd), @@ -88,7 +88,7 @@ const getConsumerKeyFromCommand = (command: string): string | null => { /** * Receives a process ID (pid) and returns both the command that the process was run with and its parent process ID. If the process is a known IDE, also returns information about that IDE. */ -const getCommandAndParentPID = async ( +export const getCommandAndParentPID = async ( pid: number, ): Promise<{ parentPID: number @@ -107,7 +107,10 @@ const getCommandAndParentPID = async ( } } -const getPathByDetectingIDE = async (): Promise => { +/** + * Detects the IDE by walking up the process tree and matching against known consumer processes + */ +export const detectIDE = async (): Promise => { // Go up the chain of ancestor process IDs and find if one of their commands matches an IDE. const ppid = process.ppid let result: Awaited> @@ -142,7 +145,7 @@ export const run = async (runOptions: RunRecipeOptions) => { } if (!consumer && process.env.AI_CONTEXT_SKIP_DETECTION !== 'true') { - consumer = await getPathByDetectingIDE() + consumer = await detectIDE() } if (!consumer) { diff --git a/src/utils/mcp-utils.ts b/src/utils/mcp-utils.ts new file mode 100644 index 00000000000..cda01c17d81 --- /dev/null +++ b/src/utils/mcp-utils.ts @@ -0,0 +1,144 @@ +import { resolve } from 'node:path' +import { promises as fs } from 'node:fs' +import { homedir } from 'node:os' +import { chalk, log, NETLIFY_CYAN, NETLIFYDEVLOG, NETLIFYDEVWARN } from './command-helpers.js' +import type { ConsumerConfig } from '../recipes/ai-context/context.js' + +/** + * Generate MCP configuration for the detected IDE or development environment + */ +export const generateMcpConfig = (ide: ConsumerConfig): Record => { + const configs: Record> = { + vscode: { + servers: { + netlify: { + type: 'stdio', + command: 'npx', + args: ['-y', '@netlify/mcp'], + }, + }, + }, + cursor: { + mcpServers: { + netlify: { + command: 'npx', + args: ['-y', '@netlify/mcp'], + }, + }, + }, + windsurf: { + mcpServers: { + netlify: { + command: 'npx', + args: ['-y', '@netlify/mcp'], + }, + }, + }, + } + + return ( + configs[ide.key] ?? { + mcpServers: { + netlify: { + command: 'npx', + args: ['-y', '@netlify/mcp'], + }, + }, + } + ) +} + +/** + * VS Code specific MCP configuration + */ +export const configureMcpForVSCode = async (config: Record, projectPath: string): Promise => { + const vscodeDirPath = resolve(projectPath, '.vscode') + const configPath = resolve(vscodeDirPath, 'mcp.json') + + try { + // Create .vscode directory if it doesn't exist + await fs.mkdir(vscodeDirPath, { recursive: true }) + + // Write or update mcp.json + let existingConfig: Record = {} + try { + const existingContent = await fs.readFile(configPath, 'utf-8') + existingConfig = JSON.parse(existingContent) as Record + } catch { + // File doesn't exist or is invalid JSON + } + + const updatedConfig = { ...existingConfig, ...config } + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8') + log(`${NETLIFYDEVLOG} VS Code MCP configuration saved to ${NETLIFY_CYAN('.vscode/mcp.json')}`) + } catch (error) { + throw new Error(`Failed to configure VS Code MCP: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +} + +/** + * Cursor specific MCP configuration + */ +export const configureMcpForCursor = async (config: Record, projectPath: string): Promise => { + const configPath = resolve(projectPath, '.cursor', 'mcp.json') + + try { + await fs.mkdir(resolve(projectPath, '.cursor'), { recursive: true }) + await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8') + log(`${NETLIFYDEVLOG} Cursor MCP configuration saved to ${NETLIFY_CYAN('.cursor/mcp.json')}`) + } catch (error) { + throw new Error(`Failed to configure Cursor MCP: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +} + +/** + * Windsurf specific MCP configuration + */ +export const configureMcpForWindsurf = async (config: Record, _projectPath: string): Promise => { + const windsurfDirPath = resolve(homedir(), '.codeium', 'windsurf') + const configPath = resolve(windsurfDirPath, 'mcp_config.json') + + try { + // Create .codeium/windsurf directory if it doesn't exist + await fs.mkdir(windsurfDirPath, { recursive: true }) + + // Read existing config or create new one + let existingConfig: Record = {} + try { + const existingContent = await fs.readFile(configPath, 'utf-8') + existingConfig = JSON.parse(existingContent) as Record + } catch { + // File doesn't exist or is invalid JSON + } + + // Merge mcpServers from both configs + const existingServers = (existingConfig.mcpServers as Record | undefined) ?? {} + const newServers = (config.mcpServers as Record | undefined) ?? {} + + const updatedConfig = { + ...existingConfig, + mcpServers: { + ...existingServers, + ...newServers, + }, + } + + await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8') + log(`${NETLIFYDEVLOG} Windsurf MCP configuration saved`) + log(`${chalk.dim('šŸ’”')} Restart Windsurf to activate the MCP server`) + } catch (error) { + throw new Error(`Failed to configure Windsurf MCP: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +} + +/** + * Generic MCP configuration display + */ +export const showGenericMcpConfig = (config: Record, ideName: string): void => { + log(`\n${NETLIFYDEVWARN} Manual configuration required`) + log(`Please add the following configuration to your ${ideName} settings:`) + log(`\n${chalk.dim('--- Configuration ---')}`) + log(JSON.stringify(config, null, 2)) + log(`${chalk.dim('--- End Configuration ---')}\n`) +}