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 +2 -2
- package/package.json +1 -1
- package/release-notes.md +14 -0
- package/src/commands/apply.js +18 -0
- package/src/commands/destroy.js +13 -2
- package/src/commands/doctor.js +6 -0
- package/src/commands/eject.js +7 -0
- package/src/commands/init.js +96 -301
- package/src/commands/secrets.js +15 -0
- package/src/core/telemetry.js +1 -0
- package/src/utils/detector.js +95 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +40 -0
- package/src/utils/prompts.js +156 -0
- package/templates/terraform/network.tf +3 -1
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
|
|
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
|
-
- [
|
|
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
package/release-notes.md
ADDED
|
@@ -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.
|
package/src/commands/apply.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/destroy.js
CHANGED
|
@@ -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
|
-
|
|
123
|
+
const actualProjectName = path.basename(process.cwd());
|
|
124
|
+
trackEvent('infrastructure_destroyed', {
|
|
125
|
+
projectName: actualProjectName,
|
|
115
126
|
region,
|
|
116
|
-
|
|
127
|
+
retained_state_bucket: !(deleteS3Bucket && typeof deleteS3Bucket !== 'symbol'),
|
|
117
128
|
success: true
|
|
118
129
|
});
|
|
119
130
|
await flushTelemetry();
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/eject.js
CHANGED
|
@@ -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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
if (procfile && procfile.web) {
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
finalFramework = detectedFramework ? detectedFramework.id : null;
|
|
52
|
+
if (isHeadless) console.log(color.cyan(`🤖 Running deploy-stack in headless mode`));
|
|
98
53
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
log.
|
|
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
|
-
//
|
|
261
|
-
const cpu =
|
|
262
|
-
const memory =
|
|
263
|
-
const computeTier =
|
|
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
|
-
//
|
|
274
|
-
|
|
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(
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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:
|
|
119
|
+
HEALTH_CHECK_PATH: config.healthCheckPath,
|
|
120
|
+
DESIRED_COUNT: config.desiredCount,
|
|
121
|
+
DEPLOY_BRANCH: config.branch,
|
|
333
122
|
BUILD_DIR: buildDir,
|
|
334
|
-
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
|
-
//
|
|
131
|
+
// 8. Telemetry
|
|
342
132
|
trackEvent('project_provisioned', {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
//
|
|
157
|
+
// 9. Output
|
|
356
158
|
let frameworkWarnings = '';
|
|
357
|
-
if (!(
|
|
358
|
-
frameworkWarnings = getFrameworkWarning(
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/secrets.js
CHANGED
|
@@ -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
|
}
|
package/src/core/telemetry.js
CHANGED
package/src/utils/detector.js
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/generator.js
CHANGED
|
@@ -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
|
+
}
|