-
Notifications
You must be signed in to change notification settings - Fork 412
feat: add ai command for cli to start the project #7352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
82710fd
6864489
a403e15
614fe51
eb21fb0
8edaf5f
1e31e7d
8633645
775fa44
04a41a3
6a36601
ad065d7
4ccd1ba
127f8b4
165f6e3
5285a5d
983aef7
eb18730
29228e6
05b325a
b7bb36c
723222a
d50b455
9e0514f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<boolean> => { | ||
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<ProjectInfo> => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be great to have a comment explaining what is the URL we're hitting β is it the Netlify API, something else? |
||
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<string> => { | ||
try { | ||
const siteInfo = (await api.getSite({ siteId: projectId })) as SiteInfo | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we not already have the site information populated by the base command? Or is this a different site? If we could use that, we'd avoid making an extra API call. |
||
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<void> => { | ||
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<void> => { | ||
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<void> => { | ||
// 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the purpose of this? |
||
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) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
part of code here is duplication, but we still not setelled the command and want to see how it feels so then we can extract stuff into common utils after we work on making command fully public
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why trialing the new command should mean more code duplication? If you move the generic methods you're reusing to a separate file (which I would argue should already be the case), you're making it easier for this PR to be reviewed, you're going to have less code to maintain, and you reduce the number of places where you might need to adjust things if an issue or an improvement come up.
If you then decide that you want to keep the new command in its current shape, great β you already have the code in the structure you want and no further changes are needed; if you decide you want to bin it, you can just delete it and the code you abstracted away into a separate file still makes sense on its own.