UNPKG

@chinchillaenterprises/mcp-amplify

Version:

AWS Amplify MCP server with intelligent deployment automation, specialized logging suite, and recursive resource discovery

1,102 lines 59.2 kB
import { ListAppsCommand, GetAppCommand, ListBranchesCommand, ListJobsCommand, GetJobCommand, CreateAppCommand, CreateBranchCommand, StartJobCommand, UpdateAppCommand } from "@aws-sdk/client-amplify"; import { ListRolesCommand, CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam"; import { GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import { getCurrentClients } from './account-handlers.js'; import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; // Default build spec template with Node 20+ and CDK support const DEFAULT_BUILD_SPEC = `version: 1 backend: phases: preBuild: commands: - nvm use 20 build: commands: - npm ci --cache .npm --prefer-offline - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID frontend: phases: preBuild: commands: - nvm use 20 - npm ci --cache .npm --prefer-offline build: commands: - env | grep -E '^NEXT_PUBLIC_' >> .env.production - env | grep -E '^AMPLIFY_' >> .env.production - npx ampx generate outputs --branch $AWS_BRANCH --app-id $AWS_APP_ID - npm run build artifacts: baseDirectory: .next files: - '**/*' cache: paths: - .next/cache/**/* - .npm/**/* - node_modules/**/*`; /** * Create and configure an IAM compute role for Amplify SSR apps * * @param appName - The Amplify app name (used for role naming) * @returns Object containing role ARN and role name */ async function createComputeRole(appName) { const clients = getCurrentClients(); // Get AWS account ID using STS const callerIdentityCommand = new GetCallerIdentityCommand({}); const callerIdentity = await clients.sts.send(callerIdentityCommand); const accountId = callerIdentity.Account; if (!accountId) { throw new Error('Failed to get AWS account ID'); } // Role naming convention: {appName}-ssr-compute-role const roleName = `${appName}-ssr-compute-role`; // Trust policy: Allow Amplify service to assume the role const trustPolicy = { Version: "2012-10-17", Statement: [ { Effect: "Allow", Principal: { Service: "amplify.amazonaws.com" }, Action: "sts:AssumeRole" } ] }; // Permissions policy: Secrets Manager access scoped to app-specific secrets const permissionsPolicy = { Version: "2012-10-17", Statement: [ { Effect: "Allow", Action: ["secretsmanager:GetSecretValue"], Resource: `arn:aws:secretsmanager:*:*:secret:${appName}-*` } ] }; try { // Create the IAM role console.error(`Creating IAM compute role: ${roleName}...`); const createRoleCommand = new CreateRoleCommand({ RoleName: roleName, AssumeRolePolicyDocument: JSON.stringify(trustPolicy), Description: `Compute role for ${appName} Amplify SSR app - Secrets Manager access scoped to ${appName}-* secrets` }); const createRoleResponse = await clients.iam.send(createRoleCommand); const roleArn = createRoleResponse.Role?.Arn; if (!roleArn) { throw new Error('Failed to create IAM role - no ARN returned'); } console.error(`✅ IAM role created: ${roleArn}`); // Attach the inline permissions policy console.error(`Attaching Secrets Manager permissions policy...`); const putRolePolicyCommand = new PutRolePolicyCommand({ RoleName: roleName, PolicyName: 'amplify-ssr-compute-policy', PolicyDocument: JSON.stringify(permissionsPolicy) }); await clients.iam.send(putRolePolicyCommand); console.error(`✅ Permissions policy attached successfully`); return { roleArn, roleName }; } catch (error) { if (error.name === 'EntityAlreadyExistsException') { // Role already exists - return existing role ARN const existingRoleArn = `arn:aws:iam::${accountId}:role/${roleName}`; console.error(`ℹ️ IAM role already exists: ${existingRoleArn}`); return { roleArn: existingRoleArn, roleName }; } throw new Error(`Failed to create IAM compute role: ${error.message || String(error)}`); } } export async function handleAmplifyListApps(args) { const { maxResults = 25 } = args; try { const clients = getCurrentClients(); const command = new ListAppsCommand({ maxResults: Math.min(maxResults, 100) }); const response = await clients.amplify.send(command); return { apps: response.apps || [], totalApps: response.apps?.length || 0, nextToken: response.nextToken || null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to list Amplify apps: ${errorMessage}`); } } export async function handleAmplifyGetAppInfo(args) { const { appId } = args; if (!appId) { throw new Error('appId is required'); } try { const clients = getCurrentClients(); // Get app details const getAppCommand = new GetAppCommand({ appId }); const appResponse = await clients.amplify.send(getAppCommand); if (!appResponse.app) { throw new Error(`App ${appId} not found`); } // Get branches const listBranchesCommand = new ListBranchesCommand({ appId, maxResults: 50 }); const branchesResponse = await clients.amplify.send(listBranchesCommand); // Enhance branch info with additional details const enhancedBranches = (branchesResponse.branches || []).map((branch) => ({ ...branch, resourceNamingInfo: { expectedLogGroups: [ `/aws/lambda/${appResponse.app.name}-${branch.branchName}-*`, `/aws/appsync/apis/${appResponse.app.name}-${branch.branchName}-*`, `/aws/cognito/userpools/${appResponse.app.name}-${branch.branchName}-*` ], stackNamePattern: `amplify-${appResponse.app.name}-${branch.branchName}-*`, resourcePrefix: `${appResponse.app.name}-${branch.branchName}` } })); return { ...appResponse.app, branches: enhancedBranches, totalBranches: enhancedBranches.length, activeBranches: enhancedBranches.filter((b) => b.enableAutoBuild).length, cloudWatchLogInfo: { appLogGroupPatterns: [ `/aws/lambda/${appResponse.app.name}-*`, `/aws/appsync/apis/*${appResponse.app.name}*`, `/aws/cognito/userpools/*${appResponse.app.name}*` ], searchPatterns: enhancedBranches.map((branch) => ({ branchName: branch.branchName, patterns: [ `/aws/lambda/${appResponse.app.name}-${branch.branchName}-*`, `/aws/appsync/apis/${appResponse.app.name}-${branch.branchName}-*` ] })) } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get app info: ${errorMessage}`); } } export async function handleAmplifyListBranches(args) { const { appId, maxResults = 50 } = args; if (!appId) { throw new Error('appId is required'); } try { const clients = getCurrentClients(); const command = new ListBranchesCommand({ appId, maxResults: Math.min(maxResults, 100) }); const response = await clients.amplify.send(command); return { appId, branches: response.branches || [], totalBranches: response.branches?.length || 0, nextToken: response.nextToken || null }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to list branches: ${errorMessage}`); } } export async function handleAmplifyGetBuildLogs(args) { const { appId, branchName, jobId, maxLines = 1000 } = args; if (!appId || !branchName) { throw new Error('appId and branchName are required'); } try { const clients = getCurrentClients(); let targetJobId = jobId; // If no jobId provided, get the latest build job if (!targetJobId) { const listJobsCommand = new ListJobsCommand({ appId, branchName, maxResults: 1 }); const jobsResponse = await clients.amplify.send(listJobsCommand); if (!jobsResponse.jobSummaries || jobsResponse.jobSummaries.length === 0) { return { appId, branchName, error: "No build jobs found for this branch", logs: [] }; } targetJobId = jobsResponse.jobSummaries[0].jobId; } // Get the job details including logs const getJobCommand = new GetJobCommand({ appId, branchName, jobId: targetJobId }); const jobResponse = await clients.amplify.send(getJobCommand); if (!jobResponse.job) { throw new Error(`Job ${targetJobId} not found`); } // Format the response const logs = jobResponse.job.steps?.flatMap((step) => step.logUrl ? [{ stepName: step.stepName, status: step.status, startTime: step.startTime, endTime: step.endTime, duration: step.endTime && step.startTime ? new Date(step.endTime).getTime() - new Date(step.startTime).getTime() : undefined, logUrl: step.logUrl, screenshots: step.screenshots }] : []) || []; return { appId, branchName, jobId: targetJobId, jobType: jobResponse.job.jobType, status: jobResponse.job.summary?.status, commitId: jobResponse.job.summary?.commitId, commitMessage: jobResponse.job.summary?.commitMessage, startTime: jobResponse.job.summary?.startTime, endTime: jobResponse.job.summary?.endTime, duration: jobResponse.job.summary?.duration, logs, totalSteps: logs.length, logMessage: "Use the logUrl for each step to view detailed logs in the AWS Console" }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get build logs: ${errorMessage}`); } } export async function handleAmplifyCreateNextjsApp(args) { const { projectName, directory = process.cwd(), installDependencies = false, initGit = false, useCurrentDirectory = false } = args; if (!projectName) { throw new Error('projectName is required'); } // Handle current directory case const isCurrentDirectory = useCurrentDirectory || projectName === '.'; // Validate project name (skip validation for current directory) if (!isCurrentDirectory && !/^[a-zA-Z0-9-_]+$/.test(projectName)) { throw new Error('Project name can only contain letters, numbers, hyphens, and underscores'); } const projectPath = isCurrentDirectory ? directory : path.join(directory, projectName); // Safety check: ensure we're not accidentally working with parent directories if (!isCurrentDirectory && projectPath === directory) { throw new Error('Invalid project configuration - would operate on parent directory'); } // Additional safety: ensure projectPath doesn't contain dangerous patterns if (projectPath.includes('..') || projectPath === '/' || projectPath === os.homedir()) { throw new Error('Invalid project path - contains dangerous patterns'); } try { // Check if directory already exists (only for new directories) if (!isCurrentDirectory) { try { await fs.access(projectPath); throw new Error(`Directory ${projectPath} already exists`); } catch (error) { if (error.code !== 'ENOENT') { throw error; } } } else { // For current directory, check if it's empty (excluding .git and common ignore files) const files = await fs.readdir(projectPath); const ignoredFiles = ['.git', '.gitignore', '.DS_Store']; const nonIgnoredFiles = files.filter(f => !ignoredFiles.includes(f)); if (nonIgnoredFiles.length > 0) { throw new Error(`Current directory is not empty. Please use an empty directory. Found: ${nonIgnoredFiles.join(', ')}`); } } // Clone the Chill Amplify Template repository console.error(`Cloning Chill Amplify Template to ${projectPath}...`); if (isCurrentDirectory) { // Clone into current directory using temporary folder const tempDir = path.join(directory, '.amplify-template-temp'); try { // Ensure temp directory doesn't exist from previous failed attempts await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { }); await execCommand('git', ['clone', 'https://github.com/ChinchillaEnterprises/chill-amplify-template.git', tempDir]); // Move contents from temp to current directory const tempFiles = await fs.readdir(tempDir); for (const file of tempFiles) { // Skip .git directory from template if (file === '.git') { continue; } const sourcePath = path.join(tempDir, file); const targetPath = path.join(projectPath, file); // Check if target already exists try { await fs.access(targetPath); // If it exists and it's .gitignore, we should merge or skip if (file === '.gitignore') { console.error(`Skipping ${file} as it already exists`); continue; } } catch (e) { // File doesn't exist, we can move it } await fs.rename(sourcePath, targetPath); } // Remove temp directory await fs.rm(tempDir, { recursive: true, force: true }); } catch (error) { // Clean up temp directory on error await fs.rm(tempDir, { recursive: true, force: true }).catch(() => { }); throw error; } } else { await execCommand('git', ['clone', 'https://github.com/ChinchillaEnterprises/chill-amplify-template.git', projectPath]); } // Delete the .git folder from the cloned template to remove the upstream connection console.error('Removing template git history...'); await fs.rm(path.join(projectPath, '.git'), { recursive: true, force: true }); // Update package.json name to match the project name if (!isCurrentDirectory) { console.error('Updating package.json name...'); const packageJsonPath = path.join(projectPath, 'package.json'); try { // Read the existing package.json const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8'); const packageJson = JSON.parse(packageJsonContent); // Update the name field to match the project name (convert to lowercase for npm compatibility) packageJson.name = projectName.toLowerCase(); // Write the updated package.json back with proper formatting await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8'); console.error(`Updated package.json name to: ${projectName}`); } catch (error) { // Log the error but don't fail the entire operation console.error(`Warning: Could not update package.json name: ${error instanceof Error ? error.message : String(error)}`); } } // Handle git initialization if (isCurrentDirectory) { // For current directory, check if git is already initialized let hasGit = false; try { await fs.access(path.join(projectPath, '.git')); hasGit = true; } catch (e) { // No git directory } if (!hasGit && initGit) { console.error('Initializing git repository...'); await execCommand('git', ['init'], { cwd: projectPath }); } // Only add and commit if we have git (either existing or just initialized) if (hasGit || initGit) { console.error('Adding Amplify template files to git...'); await execCommand('git', ['add', '-A'], { cwd: projectPath }); try { await execCommand('git', ['commit', '-m', 'Add Chill Amplify Template'], { cwd: projectPath }); } catch (e) { // Might fail if there are no changes or user hasn't set git config console.error('Note: Git commit skipped (no changes or git not configured)'); } } } else { // For new directory, handle git initialization if (initGit) { console.error('Initializing new git repository...'); await execCommand('git', ['init'], { cwd: projectPath }); // Create initial commit await execCommand('git', ['add', '-A'], { cwd: projectPath }); await execCommand('git', ['commit', '-m', 'Initial commit from Chill Amplify Template'], { cwd: projectPath }); } } // Install dependencies if requested if (installDependencies) { console.error('Installing npm dependencies (this may take a few minutes)...'); await execCommand('npm', ['install'], { cwd: projectPath }); } // Read package.json to get project info const packageJsonPath = path.join(projectPath, 'package.json'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); return { success: true, projectName: isCurrentDirectory ? path.basename(projectPath) : projectName, projectPath, templateVersion: packageJson.version || '1.0.0', dependencies: { installed: installDependencies, count: Object.keys(packageJson.dependencies || {}).length, devCount: Object.keys(packageJson.devDependencies || {}).length }, git: { initialized: initGit, remote: null }, nextSteps: [ !isCurrentDirectory ? `cd ${projectName}` : null, !installDependencies ? 'npm install' : null, 'npx ampx sandbox', 'npm run dev', 'Deploy to AWS Amplify Console when ready' ].filter(Boolean), structure: { hasAmplifyFolder: true, hasAuth: true, hasData: true, hasSrcFolder: true, isTypeScript: true, isAppRouter: true }, message: `✅ Successfully created Amplify Next.js app using Chill Amplify Template: ${isCurrentDirectory ? path.basename(projectPath) : projectName}\n\n` + `The project includes (with latest Amplify v1.16.1+):\n` + `- Pre-configured Amplify backend (auth, data)\n` + `- TypeScript setup\n` + `- Next.js App Router\n` + `- Sample todo application\n` + `- Latest package versions (no manual updates needed)\n\n` + `To get started:\n` + (isCurrentDirectory ? '' : `1. cd ${projectName}\n`) + `${isCurrentDirectory ? '1' : '2'}. Set up AWS credentials (if not already done)\n` + `${isCurrentDirectory ? '2' : '3'}. Run 'npx ampx sandbox' to deploy a cloud sandbox\n` + `${isCurrentDirectory ? '3' : '4'}. Run 'npm run dev' to start the development server` }; } catch (error) { // NO CLEANUP - if the operation fails, we leave any created files/directories intact // This is safer than risking deletion of important data const errorMessage = error instanceof Error ? error.message : String(error); console.error(`\nError occurred. Any created files have been left in place at: ${projectPath}`); console.error(`You may want to manually clean up before trying again.`); throw new Error(`Failed to create Next.js app: ${errorMessage}`); } } // Helper function to execute shell commands function execCommand(command, args, options) { return new Promise((resolve, reject) => { const proc = spawn(command, args, { stdio: 'pipe', ...options }); let stdout = ''; let stderr = ''; proc.stdout?.on('data', (data) => { stdout += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); }); proc.on('error', (error) => { reject(error); }); proc.on('close', (code) => { if (code !== 0) { reject(new Error(`Command '${command} ${args.join(' ')}' failed with code ${code}: ${stderr}`)); } else { resolve(); } }); }); } export async function handleAmplifyDeployApp(args) { const { appName, githubOwner, githubRepo, branch = 'main', platform = 'WEB_COMPUTE', environmentVariables = {}, enableBranchAutoBuild = true, enableBranchAutoDeletion = false, createComputeRole: shouldCreateComputeRole = true } = args; if (!appName || !githubOwner || !githubRepo) { throw new Error('appName, githubOwner, and githubRepo are required'); } try { const clients = getCurrentClients(); const accountState = await import('./account-handlers.js').then(m => m.getAccountState()); const activeAccount = accountState.accounts.find(acc => acc.id === accountState.activeAccountId); if (!activeAccount?.githubToken) { throw new Error('GitHub token is required. Please add an account with GitHub credentials or update the active account.'); } // Validate that the GitHub repository exists and is accessible const { Octokit } = await import('@octokit/rest'); const octokit = new Octokit({ auth: activeAccount.githubToken }); try { await octokit.repos.get({ owner: githubOwner, repo: githubRepo }); } catch (error) { if (error.status === 404) { throw new Error(`GitHub repository ${githubOwner}/${githubRepo} not found or not accessible with the provided token`); } throw new Error(`Failed to validate GitHub repository: ${error.message}`); } // STEP 1: Check if app already exists for this repository console.error(`Checking if app already exists for ${githubOwner}/${githubRepo}...`); const existingAppCheck = await handleAmplifyCheckAppExists({ githubOwner, githubRepo }); if (existingAppCheck.exists) { console.error(`Found existing app: ${existingAppCheck.app.name} (${existingAppCheck.app.appId})`); const existingApp = existingAppCheck.app; const appId = existingApp.appId; const appUrl = `https://${branch}.${appId}.amplifyapp.com`; // Check if the target branch exists const targetBranch = existingApp.branches.find((b) => b.branchName === branch); if (!targetBranch) { // Create the missing branch console.error(`Creating missing branch: ${branch}...`); const createBranchCommand = new CreateBranchCommand({ appId, branchName: branch, stage: branch === 'main' || branch === 'master' ? 'PRODUCTION' : 'DEVELOPMENT', enableAutoBuild: enableBranchAutoBuild, enableNotification: false, framework: 'Next.js - SSR', environmentVariables: environmentVariables }); await clients.amplify.send(createBranchCommand); } // STEP 2: Update build spec with Node 20+ defaults if needed console.error(`Updating build spec to Node 20+ configuration...`); try { await handleAmplifyUpdateBuildSpec({ appId }); console.error(`✅ Build spec updated successfully`); } catch (buildSpecError) { console.error(`⚠️ Build spec update failed: ${buildSpecError instanceof Error ? buildSpecError.message : String(buildSpecError)}`); } // STEP 3: Setup IAM role if needed console.error(`Checking and setting up IAM service role...`); try { const iamResult = await handleAmplifySetupIamRole({ appId }); if (iamResult.success) { console.error(`✅ IAM role assigned: ${iamResult.iamServiceRoleArn}`); } else { console.error(`⚠️ IAM role setup skipped: ${iamResult.error}`); } } catch (iamError) { console.error(`⚠️ IAM role setup failed: ${iamError instanceof Error ? iamError.message : String(iamError)}`); } // STEP 4: Trigger deployment if never deployed or force deploy const hasDeployments = existingApp.hasDeployments; if (!hasDeployments || !targetBranch?.totalNumberOfJobs) { console.error(`Triggering initial deployment for branch: ${branch}...`); try { const deploymentResult = await handleAmplifyTriggerDeployment({ appId, branchName: branch }); return { success: true, appId, appName: existingApp.name, appUrl, repository: `${githubOwner}/${githubRepo}`, branch, platform: existingApp.platform, existingApp: true, updatesApplied: { buildSpecUpdated: true, iamRoleConfigured: true, deploymentTriggered: true }, deployment: deploymentResult.deployment, nextSteps: [ `Monitor deployment progress at: https://console.aws.amazon.com/amplify/home#/${appId}/${branch}`, `View app (when ready): ${appUrl}`, 'Run "amplify_get_build_logs" to check deployment status' ], message: `✅ Successfully configured and deployed existing Amplify app: ${existingApp.name}\n\n` + `The app has been updated with Node 20+ configuration and deployment triggered.\n` + `Monitor the progress in the AWS Amplify console.` }; } catch (deployError) { return { success: false, appId, appName: existingApp.name, existingApp: true, error: `Failed to trigger deployment: ${deployError instanceof Error ? deployError.message : String(deployError)}`, suggestion: `Use amplify_trigger_deployment manually or check the AWS Amplify console.` }; } } else { // App exists and has deployments return { success: true, appId, appName: existingApp.name, appUrl, repository: `${githubOwner}/${githubRepo}`, branch, platform: existingApp.platform, existingApp: true, hasDeployments: true, updatesApplied: { buildSpecUpdated: true, iamRoleConfigured: true, deploymentTriggered: false }, branches: existingApp.branches, nextSteps: [ `View app: ${appUrl}`, 'Use "amplify_trigger_deployment" to manually trigger a new deployment', 'Use "amplify_get_build_logs" to check recent deployment status' ], message: `✅ Found existing Amplify app: ${existingApp.name}\n\n` + `The app is already configured and deployed. Configuration has been updated to Node 20+.\n` + `Use manual deployment trigger if you want to redeploy.` }; } } // STEP 5: Create new app if none exists console.error(`Creating new Amplify app: ${appName}...`); // STEP 5a: Create compute role if requested let computeRoleArn; let computeRoleName; if (shouldCreateComputeRole) { try { const computeRoleResult = await createComputeRole(appName); computeRoleArn = computeRoleResult.roleArn; computeRoleName = computeRoleResult.roleName; console.error(`✅ Compute role created: ${computeRoleName}`); } catch (computeRoleError) { console.error(`⚠️ Compute role creation failed: ${computeRoleError instanceof Error ? computeRoleError.message : String(computeRoleError)}`); console.error(`⚠️ Continuing with app creation without compute role`); } } // Create the Amplify app with updated build spec const createAppCommand = new CreateAppCommand({ name: appName, platform: platform, repository: `https://github.com/${githubOwner}/${githubRepo}`, oauthToken: activeAccount.githubToken, environmentVariables: environmentVariables, enableBranchAutoBuild: enableBranchAutoBuild, enableBranchAutoDeletion: enableBranchAutoDeletion, enableAutoBranchCreation: false, buildSpec: DEFAULT_BUILD_SPEC, // Use the new Node 20+ build spec ...(computeRoleArn && { computeRoleArn }) // Add compute role if created }); let appId; let appUrl; try { const createAppResponse = await clients.amplify.send(createAppCommand); if (!createAppResponse.app) { throw new Error('Failed to create app - no app returned'); } appId = createAppResponse.app.appId; appUrl = `https://${branch}.${appId}.amplifyapp.com`; console.error(`App created with ID: ${appId}`); } catch (error) { if (error.message?.includes('already exists') || error.message?.includes('already connected')) { throw new Error(`An Amplify app is already connected to repository ${githubOwner}/${githubRepo}. Use amplify_check_app_exists to find the existing app.`); } throw error; } // Setup IAM role for new app console.error(`Setting up IAM service role for new app...`); try { const iamResult = await handleAmplifySetupIamRole({ appId }); if (iamResult.success) { console.error(`✅ IAM role assigned: ${iamResult.iamServiceRoleArn}`); } } catch (iamError) { console.error(`⚠️ IAM role setup failed: ${iamError instanceof Error ? iamError.message : String(iamError)}`); } // Create the branch console.error(`Creating branch: ${branch}...`); const createBranchCommand = new CreateBranchCommand({ appId, branchName: branch, stage: branch === 'main' || branch === 'master' ? 'PRODUCTION' : 'DEVELOPMENT', enableAutoBuild: enableBranchAutoBuild, enableNotification: false, framework: 'Next.js - SSR', environmentVariables: environmentVariables }); await clients.amplify.send(createBranchCommand); // Start the deployment using StartJobCommand for initial build console.error(`Starting initial deployment job...`); const startJobCommand = new StartJobCommand({ appId, branchName: branch, jobType: 'RELEASE' }); const deploymentResponse = await clients.amplify.send(startJobCommand); // Build warning message for compute role const computeRoleWarning = computeRoleArn ? `\n⚠️ IMPORTANT: Compute role created with Secrets Manager access.\n` + ` Secret naming convention: All secrets for this app must be named '${appName}-*'\n` + ` Example: ${appName}-database-password, ${appName}-stripe-key, ${appName}-google-oauth-secret\n` + ` Role ARN: ${computeRoleArn}` : ''; return { success: true, appId, appName, appUrl, repository: `${githubOwner}/${githubRepo}`, branch, platform, existingApp: false, ...(computeRoleArn && { computeRole: { arn: computeRoleArn, name: computeRoleName, permissions: `Secrets Manager access scoped to: arn:aws:secretsmanager:*:*:secret:${appName}-*`, secretNamingConvention: `${appName}-*` } }), configuredFeatures: { node20BuildSpec: true, iamRoleAssigned: true, cdkSupport: true, computeRoleCreated: !!computeRoleArn }, environmentVariables: Object.keys(environmentVariables).length > 0 ? Object.keys(environmentVariables) : null, deployment: { jobId: deploymentResponse.jobSummary?.jobId, status: deploymentResponse.jobSummary?.status || 'PENDING', message: 'Deployment started. It may take 5-10 minutes to complete.' }, nextSteps: [ `Monitor deployment progress at: https://console.aws.amazon.com/amplify/home#/${appId}/${branch}`, `View app (when ready): ${appUrl}`, 'Run "amplify_get_build_logs" to check deployment status', ...(computeRoleArn ? [`Store secrets using naming convention: ${appName}-secret-name`] : []), 'Set up custom domain if needed' ], message: `✅ Successfully created and deployed Amplify app: ${appName}\n\n` + `The app has been configured with Node 20+ build spec and IAM role.\n` + `Deployment is now in progress. This typically takes 5-10 minutes.${computeRoleWarning}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to deploy Amplify app: ${errorMessage}`); } } export async function handleAmplifyCheckAppExists(args) { const { githubOwner, githubRepo } = args; if (!githubOwner || !githubRepo) { throw new Error('githubOwner and githubRepo are required'); } try { const clients = getCurrentClients(); const command = new ListAppsCommand({ maxResults: 100 }); const response = await clients.amplify.send(command); const targetRepository = `https://github.com/${githubOwner}/${githubRepo}`; const existingApp = response.apps?.find((app) => app.repository?.toLowerCase().includes(`${githubOwner}/${githubRepo}`.toLowerCase())); if (existingApp) { // Get additional app details const getAppCommand = new GetAppCommand({ appId: existingApp.appId }); const appDetails = await clients.amplify.send(getAppCommand); // Get branches const listBranchesCommand = new ListBranchesCommand({ appId: existingApp.appId, maxResults: 50 }); const branchesResponse = await clients.amplify.send(listBranchesCommand); return { exists: true, app: { appId: existingApp.appId, name: existingApp.name, repository: existingApp.repository, platform: existingApp.platform, defaultDomain: existingApp.defaultDomain, createTime: existingApp.createTime, updateTime: existingApp.updateTime, branches: branchesResponse.branches || [], totalBranches: branchesResponse.branches?.length || 0, hasDeployments: branchesResponse.branches?.some((b) => b.totalNumberOfJobs && b.totalNumberOfJobs > 0) || false } }; } return { exists: false, repository: targetRepository, message: `No Amplify app found for repository ${githubOwner}/${githubRepo}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to check if app exists: ${errorMessage}`); } } export async function handleAmplifyUpdateBuildSpec(args) { const { appId, buildSpec = DEFAULT_BUILD_SPEC } = args; if (!appId) { throw new Error('appId is required'); } try { const clients = getCurrentClients(); // First get the current app to make sure it exists const getAppCommand = new GetAppCommand({ appId }); const appResponse = await clients.amplify.send(getAppCommand); if (!appResponse.app) { throw new Error(`App ${appId} not found`); } // Update the build spec const updateAppCommand = new UpdateAppCommand({ appId, buildSpec }); const updateResponse = await clients.amplify.send(updateAppCommand); return { success: true, appId, appName: updateResponse.app?.name, message: 'Build spec updated successfully with Node 20+ configuration', buildSpec: { updated: true, usesNode20: true, supportsCDK: true, hasBackendPhase: buildSpec.includes('backend:'), hasFrontendPhase: buildSpec.includes('frontend:') } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to update build spec: ${errorMessage}`); } } export async function handleAmplifySetupIamRole(args) { const { appId, roleArn } = args; if (!appId) { throw new Error('appId is required'); } try { const clients = getCurrentClients(); // First get the current app const getAppCommand = new GetAppCommand({ appId }); const appResponse = await clients.amplify.send(getAppCommand); if (!appResponse.app) { throw new Error(`App ${appId} not found`); } let targetRoleArn = roleArn; // If no role ARN provided, try to find AmplifySSRLoggingRole if (!targetRoleArn) { try { const listRolesCommand = new ListRolesCommand({ PathPrefix: '/service-role/', MaxItems: 100 }); const rolesResponse = await clients.iam.send(listRolesCommand); const amplifyRole = rolesResponse.Roles?.find((role) => role.RoleName?.includes('AmplifySSRLoggingRole')); if (amplifyRole) { targetRoleArn = amplifyRole.Arn; } else { return { success: false, appId, error: 'No AmplifySSRLoggingRole found. Please create one or provide a specific roleArn.', suggestion: 'Create an AmplifySSRLoggingRole in the IAM console or provide a custom IAM role ARN.' }; } } catch (iamError) { return { success: false, appId, error: 'Failed to auto-detect IAM role. Please provide a specific roleArn.', iamError: iamError instanceof Error ? iamError.message : String(iamError) }; } } // Update the app with the IAM role const updateAppCommand = new UpdateAppCommand({ appId, iamServiceRoleArn: targetRoleArn }); const updateResponse = await clients.amplify.send(updateAppCommand); return { success: true, appId, appName: updateResponse.app?.name, iamServiceRoleArn: targetRoleArn, message: 'IAM service role assigned successfully', roleInfo: { assigned: true, autoDetected: !roleArn, roleArn: targetRoleArn } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to setup IAM role: ${errorMessage}`); } } export async function handleAmplifyTriggerDeployment(args) { const { appId, branchName = 'main', jobType = 'RELEASE' } = args; if (!appId) { throw new Error('appId is required'); } try { const clients = getCurrentClients(); // First verify the app and branch exist const getAppCommand = new GetAppCommand({ appId }); const appResponse = await clients.amplify.send(getAppCommand); if (!appResponse.app) { throw new Error(`App ${appId} not found`); } // Check if branch exists const listBranchesCommand = new ListBranchesCommand({ appId }); const branchesResponse = await clients.amplify.send(listBranchesCommand); const branch = branchesResponse.branches?.find((b) => b.branchName === branchName); if (!branch) { throw new Error(`Branch '${branchName}' not found in app ${appId}`); } // Start the deployment job const startJobCommand = new StartJobCommand({ appId, branchName, jobType: jobType }); const jobResponse = await clients.amplify.send(startJobCommand); return { success: true, appId, appName: appResponse.app.name, branchName, deployment: { jobId: jobResponse.jobSummary?.jobId, jobType: jobResponse.jobSummary?.jobType, status: jobResponse.jobSummary?.status, startTime: jobResponse.jobSummary?.startTime, commitId: jobResponse.jobSummary?.commitId, commitMessage: jobResponse.jobSummary?.commitMessage }, message: `Deployment triggered successfully for branch '${branchName}'`, nextSteps: [ `Monitor deployment at: https://console.aws.amazon.com/amplify/home#/${appId}/${branchName}`, `Use amplify_get_build_logs to check deployment progress`, `View app at: https://${branchName}.${appId}.amplifyapp.com (when deployment completes)` ] }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to trigger deployment: ${errorMessage}`); } } export async function handleAmplifyGetSandboxSecretCommands(args) { const { secrets, sandboxIdentifier } = args; if (!secrets || !Array.isArray(secrets) || secrets.length === 0) { throw new Error('secrets array is required and must contain at least one secret'); } // Validate secret names for (const secret of secrets) { if (!secret.name || typeof secret.name !== 'string') { throw new Error('Each secret must have a valid name'); } } // Generate commands for each secret const commands = secrets.map((secret) => { const baseCommand = 'npx ampx sandbox secret set'; const secretName = secret.name; const identifierFlag = sandboxIdentifier ? ` --identifier ${sandboxIdentifier}` : ''; return { secretName, description: secret.description || null, command: `${baseCommand} ${secretName}${identifierFlag}`, example: `# Run this command and paste your secret value when prompted:\n${baseCommand} ${secretName}${identifierFlag}` }; }); // Generate additional helpful commands const listCommand = sandboxIdentifier ? `npx ampx sandbox secret list --identifier ${sandboxIdentifier}` : 'npx ampx sandbox secret list'; const removeExampleCommand = sandboxIdentifier ? `npx ampx sandbox secret remove SECRET_NAME --identifier ${sandboxIdentifier}` : 'npx ampx sandbox secret remove SECRET_NAME'; return { success: true, totalSecrets: secrets.length, sandboxIdentifier: sandboxIdentifier || '(default sandbox)', commands, instructions: [ '📋 Run each command below in your terminal.', '🔒 You will be prompted to enter the secret value securely.', '✅ Paste your value and press Enter - no invisible characters!', '⚠️ Do NOT add these commands to scripts or CI/CD - they require interactive input.' ], quickCopyCommands: commands.map((c) => c.command), additionalCommands: { listSecrets: { command: listCommand, description: 'View all sandbox secrets (names only, not values)' }, removeSecret: { command: removeExampleCommand, description: 'Remove a secret from your sandbox' }, getSecretInfo: { command: sandboxIdentifier ? `npx ampx sandbox secret get SECRET_NAME --identifier ${sandboxIdentifier}` : 'npx ampx sandbox secret get SECRET_NAME', description: 'View secret metadata (version, last updated)' } }, sandboxInfo: { howSecretsWork: 'Secrets are stored in AWS Systems Manager Parameter Store and fetched at runtime by Lambda functions', namingPattern: sandboxIdentifier ? `/amplify/<app-id>/${sandboxIdentifier}/<secret-name>` : '/amplify/<app-id>/<username>-sandbox/<secret-name>', accessInCode: 'Use secret() in defineFunction() environment config, then access via env.<SECRET_NAME> in handler' }, nextSteps: [ 'Copy and run each command in your terminal', 'Paste secret values when prompted', 'Verify secrets are set: ' + listCommand, 'Deploy or restart your sandbox: npx ampx sandbox' ] }; } /** * Deploy a new Amplify app using CloudFormation * * Reads template and parameters from disk, deploys the CloudFormation stack, * waits for completion, and returns stack outputs. */ export async function handleAmplifyDeployAppCloudFormation(args) { const { templatePath, parametersPath, stackName } = args; if (!templatePath) { throw new Error('templatePath is required'); } try { const clients = getCurrentClients(); // Read the CloudFormation template console.error(`📄 Reading CloudFormation template from: ${templatePath}`); const templateBody = await fs.readFile(templatePath, 'utf8'); // Read parameters file if provided let parameters; if (parametersP