deploy-stack 0.12.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import fs from 'fs/promises';
4
4
  import { spinner } from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import path from 'path';
7
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
7
8
 
8
9
  export async function pushSecrets(envFilePath, projectName) {
9
10
  const s = spinner();
@@ -42,7 +43,21 @@ export async function pushSecrets(envFilePath, projectName) {
42
43
  console.log(color.cyan(`\nUpdated ${keysFilePath}`));
43
44
  console.log(color.green('Commit this file and push to GitHub to trigger a deployment with your new variables.'));
44
45
 
46
+ trackEvent('secrets_pushed', {
47
+ projectName,
48
+ secret_count: Object.keys(parsedSecrets).length,
49
+ success: true
50
+ });
51
+ await flushTelemetry();
52
+
45
53
  } catch (error) {
46
54
  s.stop(`❌ Failed to push secrets: ${error.message}`);
55
+
56
+ trackEvent('secrets_pushed', {
57
+ projectName,
58
+ success: false,
59
+ error_code: error.name || 'UNKNOWN'
60
+ });
61
+ await flushTelemetry();
47
62
  }
48
63
  }
@@ -26,6 +26,7 @@ export function trackEvent(eventName, properties) {
26
26
  properties: {
27
27
  os: process.platform,
28
28
  node_version: process.version,
29
+ is_ci: Boolean(process.env.CI || process.env.CONTINUOUS_INTEGRATION),
29
30
  ...properties
30
31
  }
31
32
  };
package/src/utils/aws.js CHANGED
@@ -21,7 +21,7 @@ export async function provisionStateBucket(region, projectName) {
21
21
  try {
22
22
  await s3Client.send(new CreateBucketCommand({
23
23
  Bucket: stateBucketName,
24
- CreateBucketConfiguration: region === 'us-east-1' ? undefined : { LocationConstraint: region }
24
+ CreateBucketConfiguration: region === 'us-east-2' ? undefined : { LocationConstraint: region }
25
25
  }));
26
26
 
27
27
  await s3Client.send(new PutBucketTaggingCommand({
@@ -1,6 +1,7 @@
1
1
  import fsSync from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ // Detects the framework based on the presence of framework-specific files.
4
5
  export function detectFramework(targetDir) {
5
6
  const packageJsonPath = path.join(targetDir, 'package.json');
6
7
  const requirementsTxtPath = path.join(targetDir, 'requirements.txt');
@@ -62,4 +63,130 @@ export function detectFramework(targetDir) {
62
63
 
63
64
  // 5. Fallback
64
65
  return null;
66
+ }
67
+
68
+ // Parses a Heroku/Render Procfile and formats the commands for Terraform ECS.
69
+ export function parseProcfile(targetDir) {
70
+ const procfilePath = path.join(targetDir, 'Procfile');
71
+
72
+ if (!fsSync.existsSync(procfilePath)) return null;
73
+
74
+ const content = fsSync.readFileSync(procfilePath, 'utf-8');
75
+ const processes = {};
76
+
77
+ // Match lines like "web: gunicorn myapp.wsgi"
78
+ const lines = content.split('\n');
79
+ const procRegex = /^([A-Za-z0-9_-]+):\s*(.+)$/;
80
+
81
+ for (const line of lines) {
82
+ const match = line.trim().match(procRegex);
83
+ if (match) {
84
+ const type = match[1].toLowerCase();
85
+ const rawCommand = match[2].trim();
86
+
87
+ // Terraform requires the command as a JSON array of strings
88
+ // This splits by spaces but respects single and double quotes
89
+ const commandArray = rawCommand.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g)
90
+ .map(str => str.replace(/^["']|["']$/g, '')); // Strip the quotes
91
+
92
+ processes[type] = commandArray;
93
+ }
94
+ }
95
+
96
+ return Object.keys(processes).length > 0 ? processes : null;
97
+ }
98
+
99
+ // Parses a vercel.json file to extract routing and edge rules
100
+ export function parseVercelConfig(targetDir) {
101
+ const vercelConfigPath = path.join(targetDir, 'vercel.json');
102
+ if (!fsSync.existsSync(vercelConfigPath)) return null;
103
+
104
+ try {
105
+ const content = fsSync.readFileSync(vercelConfigPath, 'utf-8');
106
+ const vercelJson = JSON.parse(content);
107
+
108
+ // We only care about network-level edge rules that AWS needs to handle
109
+ const rules = {
110
+ redirects: vercelJson.redirects || null,
111
+ headers: vercelJson.headers || null,
112
+ rewrites: vercelJson.rewrites || null
113
+ };
114
+
115
+ // If it's just an empty vercel.json, return null
116
+ if (!rules.redirects && !rules.headers && !rules.rewrites) {
117
+ return null;
118
+ }
119
+
120
+ return rules;
121
+ } catch (e) {
122
+ // Silently fail on malformed JSON
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // Checks if Next.js is configured for 'standalone' output
128
+ export function analyzeNextConfig(targetDir) {
129
+ const extensions = ['js', 'mjs', 'cjs', 'ts'];
130
+ let configPath = null;
131
+ let configContent = '';
132
+
133
+ for (const ext of extensions) {
134
+ const tempPath = path.join(targetDir, `next.config.${ext}`);
135
+ if (fsSync.existsSync(tempPath)) {
136
+ configPath = tempPath;
137
+ configContent = fsSync.readFileSync(tempPath, 'utf-8');
138
+ break;
139
+ }
140
+ }
141
+
142
+ if (!configPath) return { hasConfig: false, isStandalone: false };
143
+
144
+ // Regex looks for output: 'standalone' or output: "standalone" (handling spacing)
145
+ const isStandalone = /output\s*:\s*['"`]standalone['"`]/.test(configContent);
146
+
147
+ return {
148
+ hasConfig: true,
149
+ isStandalone: isStandalone,
150
+ configPath: configPath
151
+ };
152
+ }
153
+
154
+ // Checks if SvelteKit is locked into Vercel
155
+ export function analyzeSvelteConfig(targetDir) {
156
+ const configPath = path.join(targetDir, 'svelte.config.js');
157
+ if (!fsSync.existsSync(configPath)) return { hasConfig: false, adapter: 'unknown' };
158
+
159
+ const content = fsSync.readFileSync(configPath, 'utf-8');
160
+
161
+ let adapter = 'unknown';
162
+ if (content.includes('@sveltejs/adapter-vercel')) adapter = 'vercel';
163
+ else if (content.includes('@sveltejs/adapter-node')) adapter = 'node';
164
+ else if (content.includes('@sveltejs/adapter-static')) adapter = 'static';
165
+ else if (content.includes('@sveltejs/adapter-auto')) adapter = 'auto'; // Vercel's default
166
+
167
+ return { hasConfig: true, adapter };
168
+ }
169
+
170
+ // Checks if Astro is locked into Vercel
171
+ export function analyzeAstroConfig(targetDir) {
172
+ const extensions = ['mjs', 'js', 'ts', 'cjs'];
173
+ let configPath = null;
174
+ let content = '';
175
+
176
+ for (const ext of extensions) {
177
+ const tempPath = path.join(targetDir, `astro.config.${ext}`);
178
+ if (fsSync.existsSync(tempPath)) {
179
+ configPath = tempPath;
180
+ content = fsSync.readFileSync(tempPath, 'utf-8');
181
+ break;
182
+ }
183
+ }
184
+
185
+ if (!configPath) return { hasConfig: false, adapter: 'unknown' };
186
+
187
+ let adapter = 'unknown';
188
+ if (content.includes('@astrojs/vercel')) adapter = 'vercel';
189
+ else if (content.includes('@astrojs/node')) adapter = 'node';
190
+
191
+ return { hasConfig: true, adapter };
65
192
  }
@@ -0,0 +1,55 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { text, confirm, cancel, log } from '@clack/prompts';
4
+ import color from 'picocolors';
5
+
6
+ export async function resolveDjangoWsgi(targetDir, procfile, framework, isHeadless) {
7
+ if (framework !== 'django') return 'core.wsgi';
8
+
9
+ let extractedWsgi = null;
10
+ if (procfile && procfile.web) {
11
+ const webCommand = procfile.web.join(' ');
12
+ const wsgiMatch = webCommand.match(/([a-zA-Z0-9_]+)\.wsgi/);
13
+ if (wsgiMatch) extractedWsgi = `${wsgiMatch[1]}.wsgi`;
14
+ }
15
+
16
+ if (extractedWsgi) {
17
+ if (!isHeadless) log.success(`Auto-detected Django WSGI from Procfile: ${color.cyan(extractedWsgi)}`);
18
+ return extractedWsgi;
19
+ }
20
+
21
+ if (isHeadless) return 'core.wsgi';
22
+
23
+ const djangoWsgi = await text({
24
+ message: 'What is the Python module path to your Django wsgi.py?',
25
+ placeholder: 'core.wsgi',
26
+ initialValue: 'core.wsgi',
27
+ });
28
+
29
+ if (typeof djangoWsgi === 'symbol') process.exit(0);
30
+ return djangoWsgi;
31
+ }
32
+
33
+ export async function handleRailsCI(targetDir, framework, isHeadless) {
34
+ if (framework !== 'rails') return false;
35
+
36
+ const ciPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
37
+ const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
38
+
39
+ if (fsSync.existsSync(ciPath) || fsSync.existsSync(dependabotPath)) {
40
+ if (isHeadless) return true;
41
+
42
+ console.log('');
43
+ const disable = await confirm({
44
+ message: color.yellow('We detected default Rails GitHub Actions (ci.yml, dependabot.yml) that usually crash in isolated CI environments without a database. Would you like deploy-stack to safely disable them by renaming them to .bak?'),
45
+ initialValue: true,
46
+ });
47
+
48
+ if (typeof disable === 'symbol') {
49
+ cancel('Provisioning cancelled.');
50
+ process.exit(0);
51
+ }
52
+ return disable;
53
+ }
54
+ return false;
55
+ }
@@ -26,8 +26,28 @@ export async function generateTemplates(targetDir, config) {
26
26
  { src: 'README.md', dest: 'README.md' }
27
27
  ];
28
28
 
29
+ // 3. Configure Compute Commands & Environment Variables
29
30
  let secretsArray = [];
30
31
 
32
+ // 3.1. Format the ECS Task Command (Heroku Procfile Support)
33
+ // If a Procfile exists and has a 'web' process, override the Docker CMD
34
+ if (config.PROCFILE && config.PROCFILE.web) {
35
+ // e.g., ["gunicorn", "myproject.wsgi"] -> JSON string for Terraform
36
+ config.TASK_COMMAND = `command = ${JSON.stringify(config.PROCFILE.web)}`;
37
+ } else {
38
+ // Fallback to the default Dockerfile CMD
39
+ config.TASK_COMMAND = '';
40
+ }
41
+
42
+ if (config.PROCFILE && config.PROCFILE.worker) {
43
+ config.WORKER_COMMAND = `command = ${JSON.stringify(config.PROCFILE.worker)}`;
44
+ // Dynamically add worker.tf to the generation list
45
+ filesToProcess.push({ src: 'terraform/worker.tf', dest: 'terraform/worker.tf' });
46
+ } else {
47
+ config.WORKER_COMMAND = '';
48
+ }
49
+
50
+ // 3.2. Inject Managed Database Variables
31
51
  if (config.NEEDS_DATABASE) {
32
52
  filesToProcess.push({ src: 'terraform/database.tf', dest: 'terraform/database.tf' });
33
53
 
@@ -42,10 +62,11 @@ export async function generateTemplates(targetDir, config) {
42
62
  config.DB_ENV_VARS = '';
43
63
  }
44
64
 
65
+ // 4. Configure AWS Secrets Manager Integration
45
66
  // Build the initial HCL map for AWS Secrets Manager
46
67
  let initialSecretMap = `{\n EXAMPLE_API_KEY = "replace_me_in_aws_console"`;
47
68
 
48
- // Inject Rails Master Key if applicable
69
+ // 4.1. Inject Rails Master Key if applicable
49
70
  if (config.finalFramework === 'rails') {
50
71
  secretsArray.push(`{ "name": "RAILS_MASTER_KEY", "valueFrom": "\${aws_secretsmanager_secret.app_secrets.arn}:RAILS_MASTER_KEY::" }`);
51
72
 
@@ -64,7 +85,51 @@ export async function generateTemplates(targetDir, config) {
64
85
  config.TASK_SECRETS = secretsArray.join(',\n ');
65
86
  config.INITIAL_SECRET_MAP = initialSecretMap;
66
87
 
67
- // 3. Process standard files
88
+ config.SAFE_ALB_NAME = config.PROJECT_NAME.length > 27
89
+ ? config.PROJECT_NAME.substring(0, 27).replace(/-$/, '') // Remove trailing hyphens
90
+ : config.PROJECT_NAME;
91
+
92
+ // 4.5. Translate Vercel Rules to AWS ALB Listener Rules
93
+ let vercelTerraformRules = '';
94
+
95
+ if (config.VERCEL_RULES && config.VERCEL_RULES.redirects) {
96
+ config.VERCEL_RULES.redirects.forEach((rule, index) => {
97
+ // Vercel uses 'permanent: true' for 301, false for 302
98
+ const statusCode = rule.permanent === false ? "HTTP_302" : "HTTP_301";
99
+
100
+ // Map Vercel's source path to AWS ALB Path Pattern
101
+ let sourcePath = rule.source;
102
+ // Vercel sometimes uses regex syntax like '/blog/(.*)'. ALB uses '/blog/*'
103
+ sourcePath = sourcePath.replace(/\(\.\*\)/g, '*');
104
+
105
+ vercelTerraformRules += `
106
+ # Auto-generated from vercel.json redirect
107
+ resource "aws_lb_listener_rule" "vercel_redirect_${index}" {
108
+ listener_arn = aws_lb_listener.http.arn
109
+ priority = ${100 + index} # Start at 100 to avoid conflicts
110
+
111
+ action {
112
+ type = "redirect"
113
+ redirect {
114
+ status_code = "${statusCode}"
115
+ path = "${rule.destination}"
116
+ }
117
+ }
118
+
119
+ condition {
120
+ path_pattern {
121
+ values = ["${sourcePath}"]
122
+ }
123
+ }
124
+ }
125
+ `;
126
+ });
127
+ }
128
+
129
+ // Assign the compiled HCL to the config object so the template engine can inject it
130
+ config.VERCEL_EDGE_ROUTING = vercelTerraformRules;
131
+
132
+ // 5. Process standard files
68
133
  for (const file of filesToProcess) {
69
134
  let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
70
135
 
@@ -76,10 +141,10 @@ export async function generateTemplates(targetDir, config) {
76
141
  await fs.writeFile(path.join(targetDir, file.dest), content);
77
142
  }
78
143
 
79
- // 4. Create empty secrets file
144
+ // 6. Create empty secrets file
80
145
  await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
81
146
 
82
- // 5. Handle .gitignore dynamically based on framework
147
+ // 7. Handle .gitignore dynamically based on framework
83
148
  const targetGitignore = path.join(targetDir, '.gitignore');
84
149
 
85
150
  if (!fsSync.existsSync(targetGitignore)) {
@@ -92,7 +157,7 @@ export async function generateTemplates(targetDir, config) {
92
157
  }
93
158
  }
94
159
 
95
- // 6. Create .dockerignore to keep images lean and secure
160
+ // 8. Create .dockerignore to keep images lean and secure
96
161
  const dockerignorePath = path.join(targetDir, '.dockerignore');
97
162
  const appendDockerIgnore = '\n# Infrastructure (deploy-stack)\nterraform/\n**/.terraform/\n**/.terraform.*\n**/*.tfstate*\n.env\n';
98
163
 
@@ -146,7 +211,7 @@ Thumbs.db
146
211
  return (baseIgnore + frameworkIgnore).trim();
147
212
  }
148
213
 
149
- // 7. Framework-specific cleanup
214
+ // 9. Framework-specific cleanup
150
215
  // Disable default workflows that crash in isolated CI environments
151
216
  if (config.finalFramework === 'rails') {
152
217
  const railsCiPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
@@ -0,0 +1,156 @@
1
+ import path from 'path';
2
+ import { text, select, confirm, group, cancel, log } from '@clack/prompts';
3
+ import color from 'picocolors';
4
+ import { execSync } from 'child_process';
5
+
6
+ export async function getTargetDirectory(isHeadless, headlessOptions) {
7
+ if (isHeadless) {
8
+ const projectName = headlessOptions.dir || '.';
9
+ return {
10
+ projectName,
11
+ actualProjectName: projectName === '.' ? path.basename(process.cwd()) : projectName,
12
+ targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
13
+ };
14
+ }
15
+
16
+ const projectName = await text({
17
+ message: 'Where should we generate the infrastructure? (Type "." for current directory)',
18
+ placeholder: '.',
19
+ initialValue: '.',
20
+ validate: (value) => {
21
+ if (!value) return 'Please enter a name or directory.';
22
+ if (value !== '.' && value.includes(' ')) return 'Name cannot contain spaces.';
23
+ },
24
+ });
25
+
26
+ if (typeof projectName === 'symbol') {
27
+ cancel('Operation cancelled.');
28
+ process.exit(0);
29
+ }
30
+
31
+ return {
32
+ projectName,
33
+ actualProjectName: projectName === '.' ? path.basename(process.cwd()) : projectName,
34
+ targetDir: projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName)
35
+ };
36
+ }
37
+
38
+ export async function getProjectConfig(isHeadless, headlessOptions, targetDir, detectedFramework) {
39
+ if (isHeadless) {
40
+ return {
41
+ framework: headlessOptions.framework || (detectedFramework ? detectedFramework.id : 'static'),
42
+ region: headlessOptions.region || 'us-east-2',
43
+ port: headlessOptions.port || (headlessOptions.framework === 'static' ? '8080' : '3000'),
44
+ size: headlessOptions.size || 'micro',
45
+ healthCheckPath: headlessOptions.healthCheckPath || '/',
46
+ desiredCount: headlessOptions.desiredCount || '1',
47
+ branch: headlessOptions.branch || 'main',
48
+ needsDatabase: false,
49
+ setupType: 'headless'
50
+ };
51
+ }
52
+
53
+ let finalFramework = detectedFramework ? detectedFramework.id : null;
54
+
55
+ if (!finalFramework) {
56
+ finalFramework = await select({
57
+ message: 'Which framework preset should we configure?',
58
+ options: [
59
+ { value: 'node', label: 'Node.js / Express' },
60
+ { value: 'nextjs', label: 'Next.js (Standalone)' },
61
+ { value: 'nuxt', label: 'Nuxt 3 (SSR)' },
62
+ { value: 'python', label: 'Python FastAPI' },
63
+ { value: 'django', label: 'Django (Python)' },
64
+ { value: 'rails', label: 'Ruby on Rails' },
65
+ { value: 'go', label: 'Go (Golang)' },
66
+ { value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
67
+ ],
68
+ });
69
+ if (typeof finalFramework === 'symbol') process.exit(0);
70
+ }
71
+
72
+ const setupType = await select({
73
+ message: 'Choose your setup mode:',
74
+ options: [
75
+ { value: 'quick', label: '⚡ Quickstart (Recommended)', hint: 'Production defaults, minimal prompts' },
76
+ { value: 'advanced', label: '🛠️ Advanced Configuration', hint: 'Customize health checks, task count, branch, etc.' },
77
+ ],
78
+ });
79
+ if (typeof setupType === 'symbol') process.exit(0);
80
+
81
+ let defaultPort = '3000';
82
+ if (finalFramework === 'static' || finalFramework === 'go') defaultPort = '8080';
83
+ if (finalFramework === 'python' || finalFramework === 'django') defaultPort = '8000';
84
+
85
+ let currentGitBranch = 'main';
86
+ try {
87
+ currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
88
+ } catch (e) { }
89
+
90
+ let needsDatabase = false;
91
+ const isBackendFramework = ['node', 'nextjs', 'nuxt', 'python', 'django', 'rails', 'go'].includes(finalFramework);
92
+
93
+ if (isBackendFramework) {
94
+ const dbChoice = await confirm({
95
+ message: 'Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier)',
96
+ initialValue: false,
97
+ });
98
+ if (typeof dbChoice === 'symbol') process.exit(0);
99
+ needsDatabase = dbChoice;
100
+ }
101
+
102
+ const project = await group({
103
+ region: () => select({
104
+ message: 'Which AWS region do you want to deploy to?',
105
+ options: [
106
+ { value: 'us-east-1', label: 'us-east-1 (N. Virginia)' },
107
+ { value: 'us-east-2', label: 'us-east-2 (Ohio)' },
108
+ { value: 'eu-west-1', label: 'eu-west-1 (Ireland)' },
109
+ { value: 'eu-central-1', label: 'EU (Frankfurt)' },
110
+ { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' },
111
+ ],
112
+ }),
113
+ port: () => text({
114
+ message: 'What port does your container expose?',
115
+ placeholder: defaultPort,
116
+ defaultValue: defaultPort,
117
+ }),
118
+ size: () => select({
119
+ message: 'Select your Fargate compute size:',
120
+ options: [
121
+ { value: 'micro', label: 'Micro (0.25 vCPU, 512MB RAM) - Best for POCs' },
122
+ { value: 'small', label: 'Small (0.5 vCPU, 1GB RAM) - Best for small Projects' },
123
+ ],
124
+ }),
125
+ healthCheckPath: () => setupType === 'quick' ? undefined : text({
126
+ message: 'ALB Health Check Path:',
127
+ placeholder: '/',
128
+ defaultValue: '/',
129
+ }),
130
+ desiredCount: () => setupType === 'quick' ? undefined : select({
131
+ message: 'How many container replicas (tasks) should run?',
132
+ options: [
133
+ { value: '1', label: '1 Task (Single instance - lowest cost)' },
134
+ { value: '2', label: '2 Tasks (High Availability across AZs)' },
135
+ ],
136
+ defaultValue: '1',
137
+ }),
138
+ branch: () => setupType === 'quick' ? undefined : text({
139
+ message: 'Primary Git deployment branch for CI/CD:',
140
+ placeholder: currentGitBranch,
141
+ defaultValue: currentGitBranch,
142
+ }),
143
+ }, { onCancel: () => process.exit(0) });
144
+
145
+ return {
146
+ framework: finalFramework,
147
+ region: project.region,
148
+ port: project.port,
149
+ size: project.size,
150
+ healthCheckPath: project.healthCheckPath || '/',
151
+ desiredCount: project.desiredCount || '1',
152
+ branch: project.branch || currentGitBranch,
153
+ needsDatabase,
154
+ setupType
155
+ };
156
+ }
@@ -3,7 +3,7 @@ import pc from 'picocolors';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
 
6
- // Cost benchmarks for AWS us-east-1 baseline (Fargate + ALB)
6
+ // Cost benchmarks for AWS us-east-2 baseline (Fargate + ALB)
7
7
  const PRICING_TABLE = {
8
8
  fargate: {
9
9
  cpuPerHour: 0.04048, // per vCPU hour
@@ -22,7 +22,7 @@ const PRICING_TABLE = {
22
22
  // 1. Parse the local terraform files to extract the actual configuration
23
23
  export function parseTerraformConfig(tfDir) {
24
24
  const tfvarsPath = path.join(tfDir, 'terraform.tfvars');
25
- let region = 'us-east-1';
25
+ let region = 'us-east-2';
26
26
  let cpu = 256;
27
27
  let memory = 512;
28
28
  let framework = 'Application';
@@ -43,18 +43,22 @@ export function parseTerraformConfig(tfDir) {
43
43
 
44
44
  // Check if database files exist
45
45
  const hasDb = fs.existsSync(path.join(tfDir, 'rds.tf')) || fs.existsSync(path.join(tfDir, 'database.tf'));
46
+ const hasWorker = fs.existsSync(path.join(tfDir, 'worker.tf'));
46
47
 
47
- return { framework, region, cpu, memory, hasDb };
48
+ return { framework, region, cpu, memory, hasDb, hasWorker };
48
49
  }
49
50
 
50
51
  // 2. Calculate itemized monthly costs based on task definition settings
51
- export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false }) {
52
+ export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false, hasWorker = false }) {
52
53
  const vCpu = cpu / 1024;
53
54
  const memGb = memory / 1024;
54
55
  const hoursInMonth = 730;
55
56
 
57
+ // If a worker service exists, we are running a second identical Fargate task
58
+ const taskMultiplier = hasWorker ? 2 : 1;
59
+
56
60
  const fargateCost = ((vCpu * PRICING_TABLE.fargate.cpuPerHour) +
57
- (memGb * PRICING_TABLE.fargate.memoryPerHour)) * hoursInMonth;
61
+ (memGb * PRICING_TABLE.fargate.memoryPerHour)) * hoursInMonth * taskMultiplier;
58
62
  const albCost = (PRICING_TABLE.alb.basePerHour + PRICING_TABLE.alb.lcuPerHour) * hoursInMonth;
59
63
  const dbCost = hasDb ? (PRICING_TABLE.rds.microPerHour * hoursInMonth) + PRICING_TABLE.rds.storagePerMonth : 0;
60
64
 
@@ -70,17 +74,21 @@ export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false })
70
74
 
71
75
  // 3. Render the terminal architecture visualization and requests confirmation
72
76
  export async function renderDryRunPreview(config, isDryRunFlag = false) {
73
- const { framework = 'Node.js', region = 'us-east-1', cpu = 256, memory = 512, hasDb = false } = config;
74
- const cost = estimateMonthlyCost({ cpu, memory, hasDb });
77
+ const { framework = 'Node.js', region = 'us-east-2', cpu = 256, memory = 512, hasDb = false, hasWorker = false } = config;
78
+
79
+ // Fixed the duplicate hasWorker argument
80
+ const cost = estimateMonthlyCost({ cpu, memory, hasDb, hasWorker });
75
81
 
76
82
  const hourlyRate = (cost.totalMonthly / 730).toFixed(3); // 730 hours in a month
77
83
 
84
+ // Flattened the tree to eliminate nesting and vertical bloat
78
85
  const treeOutput = [
79
86
  `${pc.bold('Topology')} (${pc.cyan(region)}):`,
80
87
  ` ${pc.gray('├──')} 🌐 ${pc.bold('ALB')} (Public Entry & Health: ${pc.green('200 OK')})`,
81
88
  ` ${pc.gray('├──')} 🔒 ${pc.bold('IAM OIDC')} (GitHub Auth) & 🐳 ${pc.bold('ECR')} (Registry)`,
82
- ` ${pc.gray('└──')} 📦 ${pc.bold('ECS Fargate Cluster')} 🟢 ${pc.green(framework)} [${cpu} CPU / ${memory} MB]`,
83
- hasDb ? ` └── 🛢️ ${pc.yellow('Amazon RDS')} (PostgreSQL managed instance)` : '',
89
+ hasDb ? ` ${pc.gray('├──')} 🐘 ${pc.yellow('Amazon RDS')} (PostgreSQL managed instance)` : '',
90
+ ` ${pc.gray(hasWorker ? '├──' : '└──')} 📦 ${pc.bold('ECS Web Service')} 🟢 ${pc.green(framework)} [${cpu} CPU / ${memory} MB]`,
91
+ hasWorker ? ` ${pc.gray('└──')} 📦 ${pc.bold('ECS Worker Service')} 🔄 Background Tasks [${cpu} CPU / ${memory} MB]` : '',
84
92
  '',
85
93
  `${pc.bold('Est. Monthly Cost:')} ${pc.green(pc.bold(`~$${cost.totalMonthly}`))} ${pc.dim(`(ALB: $${cost.albMonthly}, Fargate: $${cost.fargateMonthly}${hasDb ? `, RDS: $${cost.dbMonthly}` : ''})`)}`,
86
94
  ` ${pc.dim(`* Hourly billing: ~$${hourlyRate}/hr. Destroy anytime with "npx deploy-stack destroy --yes"`)}`
@@ -1,5 +1,8 @@
1
1
  FROM python:3.12-alpine
2
2
 
3
+ # --- DevSecOps Patch: Upgrade Alpine system packages to clear OS-level CVEs ---
4
+ RUN apk upgrade --no-cache
5
+
3
6
  # Prevent Python from writing .pyc files and buffer stdout for cleaner logs
4
7
  ENV PYTHONDONTWRITEBYTECODE=1
5
8
  ENV PYTHONUNBUFFERED=1
@@ -99,6 +99,8 @@ resource "aws_ecs_task_definition" "app" {
99
99
  ]
100
100
  )
101
101
 
102
+ {{TASK_COMMAND}}
103
+
102
104
  portMappings = [
103
105
  {
104
106
  containerPort = {{PORT}}
@@ -122,7 +124,7 @@ resource "aws_ecs_task_definition" "app" {
122
124
  # --- Application Load Balancer ---
123
125
  # trivy:ignore:AVD-AWS-0053 - This ALB is intended to be publicly facing behind CloudFront
124
126
  resource "aws_lb" "main" {
125
- name = "{{PROJECT_NAME}}-alb"
127
+ name = "{{SAFE_ALB_NAME}}-alb"
126
128
  load_balancer_type = "application"
127
129
  security_groups = [aws_security_group.alb.id]
128
130
  subnets = aws_subnet.public[*].id
@@ -130,7 +132,7 @@ resource "aws_lb" "main" {
130
132
  }
131
133
 
132
134
  resource "aws_lb_target_group" "app" {
133
- name = "{{PROJECT_NAME}}-tg"
135
+ name = "{{SAFE_ALB_NAME}}-tg"
134
136
  port = {{PORT}}
135
137
  protocol = "HTTP"
136
138
  vpc_id = aws_vpc.main.id
@@ -90,4 +90,6 @@ resource "aws_security_group" "ecs_tasks" {
90
90
  # trivy:ignore:AVD-AWS-0104 - Allow containers to pull images and hit external APIs
91
91
  cidr_blocks = ["0.0.0.0/0"]
92
92
  }
93
- }
93
+ }
94
+
95
+ {{VERCEL_EDGE_ROUTING}}