deploy-stack 0.13.0 → 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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ You retain complete ownership of your infrastructure code without relying on bla
30
30
  **🚀 Zero-Config Deployments**
31
31
  * **Framework Agnostic:** Tailored container presets for Next.js, Express.js, FastAPI, Go, Django, Rails, Nuxt 3, and Static Sites (React, Vue, SvelteKit, Astro).
32
32
  * **Smart Discovery:** Automatically detects build output directories and generates highly optimized, multi-stage Dockerfiles.
33
- * **PaaS Migration Engine:** Natively parses Heroku `Procfile` configurations to automatically translate web and background worker processes (like Celery or Sidekiq) into multi-container AWS Fargate architectures.
33
+ * **PaaS Migration Engine:** Natively parses Heroku `Procfile` configurations and `vercel.json` files to automatically translate proprietary edge routing (redirects/rewrites) and background workers into standard AWS Fargate and Application Load Balancer architectures.
34
34
  * **Database Scaffolding:** Automatically provisions fully isolated, zero-trust AWS RDS PostgreSQL databases for backend monoliths.
35
35
 
36
36
  **🛡️ DevSecOps & Security**
@@ -158,7 +158,7 @@ npx deploy-stack --no-telemetry
158
158
 
159
159
  ### Phase 6: Migration & Trust Engine (Current)
160
160
  - [x] **Dry-Run Visualization:** Interactive pre-flight terminal UI with ASCII topology maps and precise, dynamic AWS cost estimation.
161
- - [ ] **PaaS Importers:** Auto-parse `vercel.json` or Heroku `Procfile` configurations to map build commands and environment variables automatically.
161
+ - [x] **PaaS Importers:** Auto-parse `vercel.json` and Heroku `Procfile` configurations to map routing rules, web commands, and background workers automatically.
162
162
  - [ ] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
163
163
  - [ ] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
164
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -0,0 +1,14 @@
1
+ # 🚀 Vercel Escape Hatch, Orchestrator Refactor & Advanced Telemetry
2
+
3
+ This release introduces the second half of our PaaS Migration Engine, focusing on seamlessly escaping Vercel's proprietary edge network, alongside an internal architectural cleanup.
4
+
5
+ ### ✨ What's New
6
+ * **Vercel Edge Routing Migration:** `deploy-stack` now automatically parses `vercel.json` files. It natively translates Vercel edge redirects (301/302) and regex path matching directly into standard AWS Application Load Balancer Listener Rules.
7
+ * **Vendor Lock-In Detection:** The CLI now proactively analyzes framework configuration files (`next.config.mjs`, `svelte.config.js`, `astro.config.mjs`) during the pre-flight checks. It warns users if they are locked into Vercel-specific adapters and provides exact instructions on how to switch to standard Node/Standalone outputs for AWS containerization.
8
+
9
+ ### 🛠️ Architecture & Telemetry
10
+ * **Core Orchestrator Refactor:** Stripped over 250 lines of business logic and UI prompting out of `init.js`, establishing a clean, strictly isolated `src/utils/` toolbox pattern for future PaaS parsers.
11
+ * **Wide Telemetry Payloads:** Upgraded the analytics engine to capture detailed execution context (CLI version, headless status, desired task counts, and specific migration vectors like Heroku/Vercel) to better map the user deployment funnel.
12
+
13
+ ### 🐛 Bug Fixes
14
+ * **Next.js Standalone Enforcement:** Explicit warnings are now surfaced if a Next.js project is missing the critical `output: 'standalone'` directive before attempting to provision cloud resources.
@@ -5,6 +5,7 @@ import { intro, outro, spinner, log, cancel } from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { renderDryRunPreview, parseTerraformConfig } from '../utils/visualizer.js';
7
7
  import { detectFramework } from '../utils/detector.js';
8
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
8
9
 
9
10
  // Helper to run a command while piping the latest stdout line into a @clack spinner
10
11
  function runTerraformCommand(args, cwd, spin, loadingPrefix) {
@@ -125,6 +126,14 @@ export async function applyStack(options = {}) {
125
126
 
126
127
  outro(`${finalMessage}\n\n ${color.yellow('Push code to deploy your app and clear the 503 error:')}\n ${color.cyan('git add . && git commit -m "ci: infra" && git push origin main')}`);
127
128
 
129
+ const actualProjectName = path.basename(process.cwd());
130
+ trackEvent('infrastructure_applied', {
131
+ projectName: actualProjectName,
132
+ framework: detectedConfig.framework,
133
+ success: true
134
+ });
135
+ await flushTelemetry();
136
+
128
137
  process.exit(0);
129
138
 
130
139
  } catch (error) {
@@ -146,6 +155,15 @@ export async function applyStack(options = {}) {
146
155
  log.message(`${color.bold('To debug manually, navigate to your terraform folder:')}`);
147
156
  log.message(color.cyan('cd terraform && terraform apply'));
148
157
  }
158
+
159
+ const actualProjectName = path.basename(process.cwd());
160
+ trackEvent('infrastructure_applied', {
161
+ projectName: actualProjectName,
162
+ success: false,
163
+ error_code: error.code || 'UNKNOWN'
164
+ });
165
+ await flushTelemetry();
166
+
149
167
  process.exit(1);
150
168
  }
151
169
  }
@@ -87,6 +87,15 @@ export async function destroyStack() {
87
87
  } catch (error) {
88
88
  s.stop(color.red('❌ Terraform destroy failed.'));
89
89
  console.error(color.red(error.message));
90
+
91
+ const actualProjectName = path.basename(process.cwd());
92
+ trackEvent('infrastructure_destroyed', {
93
+ projectName: actualProjectName,
94
+ success: false,
95
+ error_code: error.code || 'UNKNOWN'
96
+ });
97
+ await flushTelemetry();
98
+
90
99
  process.exit(1);
91
100
  }
92
101
 
@@ -111,9 +120,11 @@ export async function destroyStack() {
111
120
  }
112
121
  }
113
122
 
114
- trackEvent('project_destroyed', {
123
+ const actualProjectName = path.basename(process.cwd());
124
+ trackEvent('infrastructure_destroyed', {
125
+ projectName: actualProjectName,
115
126
  region,
116
- bucket: bucketName,
127
+ retained_state_bucket: !(deleteS3Bucket && typeof deleteS3Bucket !== 'symbol'),
117
128
  success: true
118
129
  });
119
130
  await flushTelemetry();
@@ -1,6 +1,7 @@
1
1
  import { intro, outro, spinner } from '@clack/prompts';
2
2
  import color from 'picocolors';
3
3
  import { checkDependency } from '../utils/system.js';
4
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
4
5
 
5
6
  export async function runDoctor() {
6
7
  intro(color.bgCyan(color.black(' deploy-stack ☁️ ')));
@@ -36,4 +37,9 @@ export async function runDoctor() {
36
37
  } else {
37
38
  outro(color.yellow('Please install the missing dependencies before running the provisioning tool.'));
38
39
  }
40
+
41
+ trackEvent('doctor_run', {
42
+ success: hasTerraform && hasAws && hasDocker && hasGit
43
+ });
44
+ await flushTelemetry();
39
45
  }
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { intro, outro, confirm, spinner, cancel } from '@clack/prompts';
4
4
  import color from 'picocolors';
5
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
5
6
 
6
7
  export async function ejectStack() {
7
8
  intro(color.bgRed(color.white(' deploy-stack eject ⏏️ ')));
@@ -68,6 +69,12 @@ export async function ejectStack() {
68
69
 
69
70
  s.stop('Ejection complete.');
70
71
 
72
+ const actualProjectName = path.basename(process.cwd());
73
+ trackEvent('project_ejected', {
74
+ projectName: actualProjectName
75
+ });
76
+ await flushTelemetry();
77
+
71
78
  outro(`
72
79
  ${color.green('✅ Successfully ejected from deploy-stack!')}
73
80
 
@@ -1,23 +1,33 @@
1
1
  import fsSync from 'fs';
2
- import fs from 'fs/promises';
3
2
  import path from 'path';
4
- import { intro, outro, group, text, select, spinner, cancel, confirm, log } from '@clack/prompts';
3
+ import { intro, outro, spinner, log } from '@clack/prompts';
5
4
  import color from 'picocolors';
6
- import { execSync } from 'child_process';
7
5
 
8
6
  import { checkDependency } from '../utils/system.js';
9
- import { detectFramework, parseProcfile } from '../utils/detector.js';
7
+ import {
8
+ detectFramework,
9
+ parseProcfile,
10
+ parseVercelConfig,
11
+ analyzeNextConfig,
12
+ analyzeSvelteConfig,
13
+ analyzeAstroConfig
14
+ } from '../utils/detector.js';
10
15
  import { trackEvent, flushTelemetry } from '../core/telemetry.js';
11
16
  import { getFrameworkWarning } from '../utils/warnings.js';
12
17
  import { provisionStateBucket } from '../utils/aws.js';
13
18
  import { generateTemplates } from '../utils/generator.js';
14
19
  import { handleExistingFiles } from '../utils/backup.js';
15
20
  import { estimateMonthlyCost } from '../utils/visualizer.js';
21
+ import { getTargetDirectory, getProjectConfig } from '../utils/prompts.js';
22
+ import { resolveDjangoWsgi, handleRailsCI } from '../utils/frameworks.js';
23
+
24
+ const pkg = JSON.parse(fsSync.readFileSync(new URL('../../package.json', import.meta.url)));
25
+ const CLI_VERSION = pkg.version;
16
26
 
17
27
  export async function mainStack({ isHeadless = false, headlessOptions = {} } = {}) {
18
28
  const startTime = Date.now();
19
29
 
20
- // 0. Silent Pre-flight check
30
+ // 1. Silent Pre-flight check
21
31
  const hasTerraform = await checkDependency('terraform');
22
32
  if (!hasTerraform) {
23
33
  console.error(color.red('✖ Terraform is not installed.'));
@@ -25,285 +35,65 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
25
35
  process.exit(1);
26
36
  }
27
37
 
28
- const getFlag = (key, defaultValue) => headlessOptions[key] !== undefined ? headlessOptions[key] : defaultValue;
29
-
30
- let projectName, actualProjectName, targetDir;
31
- let finalFramework, detectedFramework;
32
- let djangoWsgi = 'core.wsgi';
33
- let setupType = 'quick';
34
- let needsDatabase = false;
35
- let disableDefaultCI = false;
36
- let project = {};
37
- let currentGitBranch = 'main';
38
- let procfile = null;
39
-
40
- if (isHeadless) {
41
- // --- HEADLESS MODE ---
42
- projectName = getFlag('dir', '.');
43
- actualProjectName = projectName === '.' ? path.basename(process.cwd()) : projectName;
44
- targetDir = projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName);
45
-
46
- detectedFramework = detectFramework(targetDir);
47
- procfile = parseProcfile(targetDir);
48
- finalFramework = getFlag('framework', detectedFramework ? detectedFramework.id : 'static');
49
-
50
- project = {
51
- region: getFlag('region', 'us-east-2'),
52
- port: getFlag('port', finalFramework === 'static' ? '8080' : '3000'),
53
- size: getFlag('size', 'micro'),
54
- healthCheckPath: getFlag('healthCheckPath', '/'),
55
- desiredCount: getFlag('desiredCount', '1'),
56
- branch: getFlag('branch', 'main')
57
- };
58
-
59
- console.log(color.cyan(`🤖 Running deploy-stack in headless mode [${finalFramework} -> ${project.region}]`));
60
- } else {
61
- // --- INTERACTIVE MODE ---
62
- // 1. Start the CLI
63
- intro(color.bgCyan(color.black(' deploy-stack ☁️ ')));
38
+ if (!isHeadless) intro(color.bgCyan(color.black(' deploy-stack ☁️ ')));
64
39
 
65
- // 2. Ask for the target directory FIRST
66
- projectName = await text({
67
- message: 'Where should we generate the infrastructure? (Type "." for current directory)',
68
- placeholder: '.',
69
- initialValue: '.',
70
- validate: (value) => {
71
- if (!value) return 'Please enter a name or directory.';
72
- if (value !== '.' && value.includes(' ')) return 'Name cannot contain spaces.';
73
- },
74
- });
75
-
76
- if (typeof projectName === 'symbol') {
77
- cancel('Operation cancelled.');
78
- process.exit(0);
79
- }
80
-
81
- actualProjectName = projectName === '.' ? path.basename(process.cwd()) : projectName;
82
- targetDir = projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName);
83
-
84
- // 2.5 Run the scanner
85
- const detectedFramework = detectFramework(targetDir);
86
- if (detectedFramework) {
87
- log.success(`Auto-detected framework: ${detectedFramework.name}`);
88
- }
40
+ // 2. Resolve Target & Scan Codebase
41
+ const dirConfig = await getTargetDirectory(isHeadless, headlessOptions);
42
+ const detectedFramework = detectFramework(dirConfig.targetDir);
43
+ const procfile = parseProcfile(dirConfig.targetDir);
44
+ const vercelRules = parseVercelConfig(dirConfig.targetDir);
89
45
 
90
- // 2.6 Run the Procfile Parser
91
- procfile = parseProcfile(targetDir);
92
- if (procfile && procfile.web) {
93
- log.success(`Auto-detected Procfile (web command: ${procfile.web.join(' ')})`);
94
- }
46
+ if (!isHeadless) {
47
+ if (detectedFramework) log.success(`Auto-detected framework: ${detectedFramework.name}`);
48
+ if (procfile && procfile.web) log.success(`Auto-detected Procfile (web command: ${procfile.web.join(' ')})`);
49
+ if (vercelRules) log.success(`Auto-detected vercel.json (Migrating edge network rules)`);
50
+ }
95
51
 
96
- // 2.7 Resolve the framework
97
- finalFramework = detectedFramework ? detectedFramework.id : null;
52
+ if (isHeadless) console.log(color.cyan(`🤖 Running deploy-stack in headless mode`));
98
53
 
99
- if (!finalFramework) {
100
- finalFramework = await select({
101
- message: 'Which framework preset should we configure?',
102
- options: [
103
- { value: 'node', label: 'Node.js / Express' },
104
- { value: 'nextjs', label: 'Next.js (Standalone)' },
105
- { value: 'nuxt', label: 'Nuxt 3 (SSR)' },
106
- { value: 'python', label: 'Python FastAPI' },
107
- { value: 'django', label: 'Django (Python)' },
108
- { value: 'rails', label: 'Ruby on Rails' },
109
- { value: 'go', label: 'Go (Golang)' },
110
- { value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
111
- ],
112
- });
54
+ // 3. Gather Configuration & Framework Quirks
55
+ const config = await getProjectConfig(isHeadless, headlessOptions, dirConfig.targetDir, detectedFramework);
56
+ const djangoWsgi = await resolveDjangoWsgi(dirConfig.targetDir, procfile, config.framework, isHeadless);
57
+ const disableDefaultCI = await handleRailsCI(dirConfig.targetDir, config.framework, isHeadless);
113
58
 
114
- if (typeof finalFramework === 'symbol') {
115
- cancel('Operation cancelled.');
116
- process.exit(0);
59
+ // 4. Framework Migration Checks (Vercel Escape Hatch)
60
+ if (!isHeadless) {
61
+ if (config.framework === 'nextjs') {
62
+ const nextConfig = analyzeNextConfig(dirConfig.targetDir);
63
+ if (nextConfig.hasConfig && !nextConfig.isStandalone) {
64
+ log.warn(color.yellow('⚠️ Next.js config is missing "output: \'standalone\'". Your CI/CD Docker build will crash until you add it!'));
117
65
  }
118
- }
119
-
120
- // 2.8 Check if framework is Django and resolve wsgi.py path
121
- djangoWsgi = 'core.wsgi';
122
- if (finalFramework === 'django') {
123
- let extractedWsgi = null;
124
-
125
- // Check if Procfile already specifies the WSGI module
126
- if (procfile && procfile.web) {
127
- const webCommand = procfile.web.join(' ');
128
- // Matches patterns like "gunicorn my_app.wsgi" or "my_app.wsgi:application"
129
- const wsgiMatch = webCommand.match(/([a-zA-Z0-9_]+)\.wsgi/);
130
- if (wsgiMatch) {
131
- extractedWsgi = `${wsgiMatch[1]}.wsgi`;
132
- }
66
+ } else if (detectedFramework?.name === 'SvelteKit') {
67
+ const svelteConfig = analyzeSvelteConfig(dirConfig.targetDir);
68
+ if (svelteConfig.adapter === 'vercel' || svelteConfig.adapter === 'auto') {
69
+ log.warn(color.yellow('⚠️ SvelteKit is locked into the Vercel/Auto adapter. Switch to @sveltejs/adapter-node (for SSR) to deploy on AWS.'));
133
70
  }
134
-
135
- if (extractedWsgi) {
136
- djangoWsgi = extractedWsgi;
137
- log.success(`Auto-detected Django WSGI from Procfile: ${color.cyan(djangoWsgi)}`);
138
- } else {
139
- djangoWsgi = await text({
140
- message: 'What is the Python module path to your Django wsgi.py?',
141
- placeholder: 'core.wsgi',
142
- initialValue: 'core.wsgi',
143
- });
144
- if (typeof djangoWsgi === 'symbol') process.exit(0);
71
+ } else if (detectedFramework?.name === 'Astro') {
72
+ const astroConfig = analyzeAstroConfig(dirConfig.targetDir);
73
+ if (astroConfig.adapter === 'vercel') {
74
+ log.warn(color.yellow('⚠️ Astro is locked into the Vercel adapter. Switch to @astrojs/node (for SSR) to deploy on AWS.'));
145
75
  }
146
76
  }
147
-
148
- // 3. Prompt for Setup Mode
149
- setupType = await select({
150
- message: 'Choose your setup mode:',
151
- options: [
152
- { value: 'quick', label: '⚡ Quickstart (Recommended)', hint: 'Production defaults, minimal prompts' },
153
- { value: 'advanced', label: '🛠️ Advanced Configuration', hint: 'Customize health checks, task count, branch, etc.' },
154
- ],
155
- });
156
-
157
- if (typeof setupType === 'symbol') {
158
- cancel('Operation cancelled.');
159
- process.exit(0);
160
- }
161
-
162
- // 4. Set intelligent defaults & check current Git branch
163
- let defaultPort = '3000';
164
-
165
- if (finalFramework === 'static') defaultPort = '8080';
166
- if (finalFramework === 'python' || finalFramework === 'django') defaultPort = '8000';
167
- if (finalFramework === 'rails') defaultPort = '3000';
168
- if (finalFramework === 'go') defaultPort = '8080';
169
-
170
- currentGitBranch = 'main';
171
- try {
172
- currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
173
- } catch (e) {
174
- // Not a git repo yet, fallback to 'main'
175
- }
176
-
177
- // 5. Ask for Managed Database (Only for Backend/Fullstack Frameworks)
178
- needsDatabase = false;
179
- const isBackendFramework = ['node', 'nextjs', 'nuxt', 'python', 'django', 'rails', 'go'].includes(finalFramework);
180
-
181
- if (isBackendFramework) {
182
- const dbChoice = await confirm({
183
- message: 'Do you need a managed AWS RDS PostgreSQL database? (Adds ~$14/month or uses AWS Free Tier)',
184
- initialValue: false,
185
- });
186
-
187
- if (typeof dbChoice === 'symbol') {
188
- cancel('Provisioning cancelled.')
189
- process.exit(0);
190
- }
191
- needsDatabase = dbChoice;
192
- }
193
-
194
- // 6. Prompt Configuration Group
195
- project = await group(
196
- {
197
- region: () =>
198
- select({
199
- message: 'Which AWS region do you want to deploy to?',
200
- options: [
201
- { value: 'us-east-1', label: 'us-east-1 (N. Virginia)' },
202
- { value: 'us-east-2', label: 'us-east-2 (Ohio)' },
203
- { value: 'eu-west-1', label: 'eu-west-1 (Ireland)' },
204
- { value: 'eu-central-1', label: 'EU (Frankfurt)' },
205
- { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' },
206
- ],
207
- }),
208
- port: () =>
209
- text({
210
- message: 'What port does your container expose?',
211
- placeholder: defaultPort,
212
- defaultValue: defaultPort,
213
- }),
214
- size: () =>
215
- select({
216
- message: 'Select your Fargate compute size:',
217
- options: [
218
- { value: 'micro', label: 'Micro (0.25 vCPU, 512MB RAM) - Best for POCs' },
219
- { value: 'small', label: 'Small (0.5 vCPU, 1GB RAM) - Best for small Projects' },
220
- ],
221
- }),
222
- // --- Advanced-Only Prompts (Skipped if setupType === 'quick') ---
223
- healthCheckPath: () => {
224
- if (setupType === 'quick') return undefined;
225
- return text({
226
- message: 'ALB Health Check Path:',
227
- placeholder: '/',
228
- defaultValue: '/',
229
- });
230
- },
231
- desiredCount: () => {
232
- if (setupType === 'quick') return undefined;
233
- return select({
234
- message: 'How many container replicas (tasks) should run?',
235
- options: [
236
- { value: '1', label: '1 Task (Single instance - lowest cost)' },
237
- { value: '2', label: '2 Tasks (High Availability across AZs)' },
238
- ],
239
- defaultValue: '1',
240
- });
241
- },
242
- branch: () => {
243
- if (setupType === 'quick') return undefined;
244
- return text({
245
- message: 'Primary Git deployment branch for CI/CD:',
246
- placeholder: currentGitBranch,
247
- defaultValue: currentGitBranch,
248
- });
249
- },
250
- },
251
- {
252
- onCancel: () => {
253
- cancel('Provisioning cancelled.');
254
- process.exit(0);
255
- },
256
- }
257
- );
258
77
  }
259
78
 
260
- // 7. Map the user's choices and update the variables
261
- const cpu = project.size === 'small' ? '512' : '256';
262
- const memory = project.size === 'small' ? '1024' : '512';
263
- const computeTier = project.size === 'small' ? 'Small (0.5 vCPU, 1GB RAM)' : 'Micro (0.25 vCPU, 512MB RAM)';
79
+ // 5. Calculate Derived Values
80
+ const cpu = config.size === 'small' ? '512' : '256';
81
+ const memory = config.size === 'small' ? '1024' : '512';
82
+ const computeTier = config.size === 'small' ? 'Small (0.5 vCPU, 1GB RAM)' : 'Micro (0.25 vCPU, 512MB RAM)';
264
83
 
265
- const costs = estimateMonthlyCost({ cpu: parseInt(cpu), memory: parseInt(memory), hasDb: needsDatabase });
266
- const estimatedCost = `~$${costs.totalMonthly} / month${needsDatabase ? ' (Includes Fargate + RDS PostgreSQL)' : ''}`;
267
-
268
- const healthCheckPath = project.healthCheckPath || '/';
269
- const desiredCount = project.desiredCount || '1';
270
- const deployBranch = project.branch || currentGitBranch;
84
+ const costs = estimateMonthlyCost({ cpu: parseInt(cpu), memory: parseInt(memory), hasDb: config.needsDatabase });
85
+ const estimatedCost = `~$${costs.totalMonthly} / month${config.needsDatabase ? ' (Includes Fargate + RDS PostgreSQL)' : ''}`;
271
86
  const buildDir = detectedFramework?.buildDir || 'dist';
272
87
 
273
- // 7.1 Check for conflicting CI boilerplate (Rails)
274
- if (finalFramework === 'rails') {
275
- const ciPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
276
- const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
277
-
278
- if (fsSync.existsSync(ciPath) || fsSync.existsSync(dependabotPath)) {
279
- if (!isHeadless) {
280
- console.log('');
281
- const ciContent = await confirm({
282
- 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?'),
283
- initialValue: true,
284
- });
285
- if (typeof ciContent === 'symbol') {
286
- cancel('Provisioning cancelled.');
287
- process.exit(0);
288
- }
289
- disableDefaultCI = ciContent;
290
- } else {
291
- // Headless: automatically disable conflicting CI
292
- disableDefaultCI = true;
293
- }
294
- }
295
- }
296
-
297
- // 8. Safely handle existing files (Backup and auto-prune)
298
- await handleExistingFiles(targetDir, isHeadless);
88
+ // 6. Handle Backups & Provision Remote State
89
+ await handleExistingFiles(dirConfig.targetDir, isHeadless);
299
90
 
300
91
  const s = spinner();
301
92
  s.start('Provisioning infrastructure...');
302
93
 
303
- // 9. Provision S3 bucket for Terraform state & enable versioning
304
94
  let awsAccountId, stateBucketName;
305
95
  try {
306
- const bucketData = await provisionStateBucket(project.region, actualProjectName);
96
+ const bucketData = await provisionStateBucket(config.region, dirConfig.actualProjectName);
307
97
  awsAccountId = bucketData.awsAccountId;
308
98
  stateBucketName = bucketData.stateBucketName;
309
99
  } catch (error) {
@@ -314,70 +104,75 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
314
104
  process.exit(1);
315
105
  }
316
106
 
107
+ // 7. Synthesize Templates
317
108
  s.message('Synthesizing Terraform templates...');
318
-
319
- // 10. Generate all templates and directories
320
- await generateTemplates(targetDir, {
321
- PROJECT_NAME: actualProjectName,
322
- REGION: project.region,
323
- PORT: project.port,
109
+ await generateTemplates(dirConfig.targetDir, {
110
+ PROJECT_NAME: dirConfig.actualProjectName,
111
+ REGION: config.region,
112
+ PORT: config.port,
324
113
  CPU: cpu,
325
114
  MEMORY: memory,
326
115
  COMPUTE_TIER: computeTier,
327
116
  ESTIMATED_COST: estimatedCost,
328
117
  STATE_BUCKET: stateBucketName,
329
118
  AWS_ACCOUNT_ID: awsAccountId,
330
- HEALTH_CHECK_PATH: healthCheckPath,
331
- DESIRED_COUNT: desiredCount,
332
- DEPLOY_BRANCH: deployBranch,
119
+ HEALTH_CHECK_PATH: config.healthCheckPath,
120
+ DESIRED_COUNT: config.desiredCount,
121
+ DEPLOY_BRANCH: config.branch,
333
122
  BUILD_DIR: buildDir,
334
- finalFramework: finalFramework,
335
- NEEDS_DATABASE: needsDatabase,
123
+ finalFramework: config.framework,
124
+ NEEDS_DATABASE: config.needsDatabase,
336
125
  DJANGO_WSGI: djangoWsgi,
337
126
  DISABLE_DEFAULT_CI: disableDefaultCI,
338
- PROCFILE: procfile
127
+ PROCFILE: procfile,
128
+ VERCEL_RULES: vercelRules
339
129
  });
340
130
 
341
- // 11. Track the event in telemetry
131
+ // 8. Telemetry
342
132
  trackEvent('project_provisioned', {
343
- projectName: actualProjectName,
344
- framework: finalFramework,
345
- specific_framework: detectedFramework?.name || finalFramework,
346
- region: project.region,
347
- size: project.size,
348
- setup_mode: setupType,
349
- desiredCount: desiredCount,
350
- duration_ms: Date.now() - startTime
133
+ // 1. Core & Context
134
+ projectName: dirConfig.actualProjectName,
135
+ cli_version: CLI_VERSION,
136
+ is_headless: isHeadless,
137
+ setup_mode: config.setupType,
138
+ duration_ms: Date.now() - startTime,
139
+
140
+ // 2. Infrastructure Shape
141
+ framework: config.framework,
142
+ specific_framework: detectedFramework?.name || config.framework,
143
+ region: config.region,
144
+ size: config.size,
145
+ desired_count: parseInt(config.desiredCount),
146
+ has_database: config.needsDatabase,
147
+ has_custom_health_check: config.healthCheckPath !== '/',
148
+
149
+ // 3. Advanced Features & PaaS Context
150
+ has_worker: !!(procfile && procfile.worker),
151
+ is_heroku_migration: !!procfile,
152
+ is_vercel_migration: !!vercelRules,
351
153
  });
352
154
 
353
155
  s.stop('Infrastructure provisioned successfully!');
354
156
 
355
- // 12. Provide the Outro, Framework Warnings and Next Steps
157
+ // 9. Output
356
158
  let frameworkWarnings = '';
357
- if (!(finalFramework === 'static' && detectedFramework?.buildDir)) {
358
- frameworkWarnings = getFrameworkWarning(finalFramework);
159
+ if (!(config.framework === 'static' && detectedFramework?.buildDir)) {
160
+ frameworkWarnings = getFrameworkWarning(config.framework);
359
161
  }
360
162
 
361
- const isGitInitialized = fsSync.existsSync(path.join(targetDir, '.git'));
362
-
363
- const needsCd = projectName && projectName !== '.';
364
- const applyStep = needsCd
365
- ? `cd ${projectName} && npx --yes deploy-stack apply`
366
- : 'npx --yes deploy-stack apply';
367
-
163
+ const isGitInitialized = fsSync.existsSync(path.join(dirConfig.targetDir, '.git'));
164
+ const needsCd = dirConfig.projectName && dirConfig.projectName !== '.';
165
+ const applyStep = needsCd ? `cd ${dirConfig.projectName} && npx --yes deploy-stack apply` : 'npx --yes deploy-stack apply';
368
166
  const gitInstructions = isGitInitialized
369
167
  ? `git add . && git commit -m "chore: add AWS infrastructure and CI/CD" && git push`
370
- : `git init && git add . && git commit -m "chore: add AWS infrastructure and CI/CD" && git branch -M ${deployBranch} && git remote add origin https://github.com/your-username/your-repo.git && git push -u origin ${deployBranch}`;
168
+ : `git init && git add . && git commit -m "chore: add AWS infrastructure and CI/CD" && git branch -M ${config.branch} && git remote add origin https://github.com/your-username/your-repo.git && git push -u origin ${config.branch}`;
371
169
 
372
- const outroMessage = `${color.green('✅ Templates generated!')} ${color.blue('🛡️ DevSecOps scanning enabled.')}
170
+ outro(`${color.green('✅ Templates generated!')} ${color.blue('🛡️ DevSecOps scanning enabled.')}
373
171
  ${frameworkWarnings ? `\n ${frameworkWarnings}` : ''}
374
172
  ${color.yellow('Next steps:')}
375
173
  1. ${color.cyan(applyStep)}
376
174
  2. ${color.cyan(gitInstructions)}
377
- ${color.magenta('🚀 Need help?')} ${color.underline('https://calendly.com/anton-codes-iac/15min')}`;
378
-
379
- outro(outroMessage);
175
+ ${color.magenta('🚀 Need help?')} ${color.underline('https://calendly.com/anton-codes-iac/15min')}`);
380
176
 
381
- // 13.Ensure all analytics are sent before the CLI terminates
382
177
  await flushTelemetry();
383
178
  }
@@ -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
  };
@@ -94,4 +94,99 @@ export function parseProcfile(targetDir) {
94
94
  }
95
95
 
96
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 };
97
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
+ }
@@ -89,6 +89,46 @@ export async function generateTemplates(targetDir, config) {
89
89
  ? config.PROJECT_NAME.substring(0, 27).replace(/-$/, '') // Remove trailing hyphens
90
90
  : config.PROJECT_NAME;
91
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
+
92
132
  // 5. Process standard files
93
133
  for (const file of filesToProcess) {
94
134
  let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
@@ -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
+ }
@@ -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}}