@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
JavaScript
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