deploy-stack 0.11.1 → 0.12.2

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
@@ -68,6 +68,7 @@ The interactive wizard will analyze your codebase, detect your framework, estima
68
68
 
69
69
  * **`npx deploy-stack apply`**
70
70
  Wraps Terraform execution in a beautiful, terminal-friendly UI. Automatically provisions your AWS infrastructure and outputs your live CDN and Load Balancer URLs.
71
+ *Tip: Append `--dry-run` to preview the architecture topology and estimated cost without provisioning anything.*
71
72
 
72
73
  * **`npx deploy-stack secrets push <file>`**
73
74
  Securely encrypts your local environment variables (e.g., `.env.production`) into AWS Secrets Manager and maps them to your ECS container at runtime.
@@ -143,22 +144,21 @@ npx deploy-stack --no-telemetry
143
144
  - [x] **Smart Experience:** Zero-config framework auto-discovery for static output directories.
144
145
  - [x] **Trust & Observability:** DevSecOps Trivy scanning, automated 5XX alarms, 14-day log retention, and safe local overwrite protections.
145
146
 
146
- ### Phase 4: Trust Anchors & TAM Expansion (v0.9.0 - Current)
147
+ ### Phase 4: Trust Anchors & TAM Expansion (Completed)
147
148
  - [x] **Ecosystem Distribution:** Native GitHub Marketplace Action for rapid discovery.
148
149
  - [x] **Cost Transparency:** Pre-flight AWS cost estimator injected directly into the CLI wizard.
149
150
  - [x] **Zero Vendor Lock-In:** Explicit `npx deploy-stack eject` command to safely strip `ManagedBy` tags and CLI metadata, leaving behind pure IaC.
150
151
  - [x] **Heavy Backend Monoliths:** Hardened, unprivileged container adapters for Go, Nuxt.js, Django, and Rails, complete with automated zero-trust RDS PostgreSQL provisioning.
151
152
 
152
- ### Phase 5: The Activation Engine (v0.10.0 - Current)
153
+ ### Phase 5: The Activation Engine (Completed)
153
154
  - [x] **Local Execution Wrapper:** Native `deploy-stack apply` command with terminal-optimized streaming to eliminate Terraform context switching.
154
- - [x] **Ecosystem Integrations:** Publishing official plugins to the Astro Integrations directory and backend framework ecosystems.
155
- - [ ] **Ephemeral PR Previews:** Generating live preview URLs on every GitHub Pull Request, turning single-user tests into team-wide advertisements.
156
- - [ ] **GitHub Deployments UI Sync:** Wiring up the native GitHub "Environments" tab for instant visual validation that the CLI succeeded in the background.
155
+ - [x] **Ecosystem Integrations:** Official plugins published to the Astro Integrations directory (`astro-deploy-stack`) and Nuxt module registry (`nuxt-deploy-stack`).
157
156
 
158
- ### Phase 6: Workflow Interception (IDE, AI, & Local Bridges)
159
- - [ ] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
157
+ ### Phase 6: Migration & Trust Engine (Current)
158
+ - [x] **Dry-Run Visualization:** Interactive pre-flight terminal UI with ASCII topology maps and precise, dynamic AWS cost estimation.
159
+ - [ ] **PaaS Importers:** Auto-parse `vercel.json` or Heroku `Procfile` configurations to map build commands and environment variables automatically.
160
160
  - [ ] **Docker Compose to ECS Translator:** Automatically converting a familiar local `docker-compose.yml` into production ECS task definitions.
161
- - [ ] **Dry-Run Visualization:** Generating a local `docker-compose.yml` mirror and a visual architecture map for local infrastructure validation.
161
+ - [ ] **AI Agent Rulesets:** Publishing `.cursorrules` and Copilot instructions that teach AI assistants exactly how to utilize the CLI on the user's behalf.
162
162
 
163
163
  ---
164
164
 
package/bin/cli.js CHANGED
@@ -20,6 +20,7 @@ const args = rawArgs.filter((arg) => arg !== '--no-telemetry');
20
20
 
21
21
  // 3. Parse headless flags
22
22
  const isHeadless = args.includes('--headless');
23
+ const isDryRun = args.includes('--dry-run');
23
24
  const getFlag = (flagName) => {
24
25
  const match = args.find(a => a.startsWith(`--${flagName}=`));
25
26
  return match ? match.split('=')[1] : undefined;
@@ -41,7 +42,7 @@ if (args[0] === 'secrets' && args[1] === 'push') {
41
42
  const projectName = path.basename(process.cwd());
42
43
  pushSecrets(envFile, projectName).catch(console.error);
43
44
  } else if (args[0] === 'apply') {
44
- applyStack().catch(console.error);
45
+ applyStack({ isDryRun }).catch(console.error);
45
46
  } else if (args[0] === 'doctor') {
46
47
  runDoctor().catch(console.error);
47
48
  } else if (args[0] === 'destroy') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.11.1",
3
+ "version": "0.12.2",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -3,6 +3,8 @@ import path from 'path';
3
3
  import fs from 'fs';
4
4
  import { intro, outro, spinner, log, cancel } from '@clack/prompts';
5
5
  import color from 'picocolors';
6
+ import { renderDryRunPreview, parseTerraformConfig } from '../utils/visualizer.js';
7
+ import { detectFramework } from '../utils/detector.js';
6
8
 
7
9
  // Helper to run a command while piping the latest stdout line into a @clack spinner
8
10
  function runTerraformCommand(args, cwd, spin, loadingPrefix) {
@@ -69,7 +71,7 @@ function getTerraformOutputs(cwd) {
69
71
  });
70
72
  }
71
73
 
72
- export async function applyStack() {
74
+ export async function applyStack(options = {}) {
73
75
  intro(color.bgCyan(color.black(' deploy-stack apply ☁️ ')));
74
76
 
75
77
  const targetDir = process.cwd();
@@ -82,20 +84,36 @@ export async function applyStack() {
82
84
  process.exit(1);
83
85
  }
84
86
 
87
+ // 2. Read the actual AWS configuration from disk (CPU, Memory, Region, Database)
88
+ const detectedConfig = parseTerraformConfig(tfDir);
89
+
90
+ // 3. Detect the framework name using your existing detector
91
+ const detectedFw = detectFramework(targetDir);
92
+ detectedConfig.framework = detectedFw ? detectedFw.name : 'Container';
93
+
94
+ // 4. Run the visualizer (passing the flag so it knows whether to prompt)
95
+ if (options.isDryRun) {
96
+ await renderDryRunPreview(detectedConfig, true);
97
+ outro(color.green('Dry run complete. No infrastructure was provisioned.'));
98
+ process.exit(0);
99
+ } else {
100
+ await renderDryRunPreview(detectedConfig, false);
101
+ }
102
+
85
103
  const s = spinner();
86
104
 
87
105
  try {
88
- // 2. Run terraform init
106
+ // 5. Run terraform init
89
107
  s.start('Initializing Terraform plugins...');
90
108
  await runTerraformCommand(['init', '-upgrade'], tfDir, s, 'Initializing');
91
109
  log.success('Terraform initialized.');
92
110
 
93
- // 3. Run terraform apply
111
+ // 6. Run terraform apply
94
112
  s.start('Provisioning AWS infrastructure (this may take 3–5 minutes)...');
95
113
  await runTerraformCommand(['apply', '-auto-approve'], tfDir, s, 'Provisioning');
96
114
  s.stop('AWS infrastructure provisioned successfully!');
97
115
 
98
- // 4. Extract and print the outputs
116
+ // 7. Extract and print the outputs
99
117
  const outputs = await getTerraformOutputs(tfDir);
100
118
 
101
119
  const cfUrl = outputs.cloudfront_url?.value;
@@ -12,6 +12,7 @@ import { getFrameworkWarning } from '../utils/warnings.js';
12
12
  import { provisionStateBucket } from '../utils/aws.js';
13
13
  import { generateTemplates } from '../utils/generator.js';
14
14
  import { handleExistingFiles } from '../utils/backup.js';
15
+ import { estimateMonthlyCost } from '../utils/visualizer.js';
15
16
 
16
17
  export async function mainStack({ isHeadless = false, headlessOptions = {} } = {}) {
17
18
  const startTime = Date.now();
@@ -60,7 +61,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
60
61
  intro(color.bgCyan(color.black(' deploy-stack ☁️ ')));
61
62
 
62
63
  // 2. Ask for the target directory FIRST
63
- const projectName = await text({
64
+ projectName = await text({
64
65
  message: 'Where should we generate the infrastructure? (Type "." for current directory)',
65
66
  placeholder: '.',
66
67
  initialValue: '.',
@@ -75,8 +76,8 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
75
76
  process.exit(0);
76
77
  }
77
78
 
78
- const actualProjectName = projectName === '.' ? path.basename(process.cwd()) : projectName;
79
- const targetDir = projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName);
79
+ actualProjectName = projectName === '.' ? path.basename(process.cwd()) : projectName;
80
+ targetDir = projectName === '.' ? process.cwd() : path.join(process.cwd(), projectName);
80
81
 
81
82
  // 2.5 Run the scanner
82
83
  const detectedFramework = detectFramework(targetDir);
@@ -85,7 +86,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
85
86
  }
86
87
 
87
88
  // 2.6 Resolve the framework
88
- let finalFramework = detectedFramework ? detectedFramework.id : null;
89
+ finalFramework = detectedFramework ? detectedFramework.id : null;
89
90
 
90
91
  if (!finalFramework) {
91
92
  finalFramework = await select({
@@ -109,7 +110,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
109
110
  }
110
111
 
111
112
  // --- DJANGO SPECIFIC PROMPT ---
112
- let djangoWsgi = 'core.wsgi';
113
+ djangoWsgi = 'core.wsgi';
113
114
  if (finalFramework === 'django') {
114
115
  djangoWsgi = await text({
115
116
  message: 'What is the Python module path to your Django wsgi.py?',
@@ -120,7 +121,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
120
121
  }
121
122
 
122
123
  // 3. Prompt for Setup Mode
123
- const setupType = await select({
124
+ setupType = await select({
124
125
  message: 'Choose your setup mode:',
125
126
  options: [
126
127
  { value: 'quick', label: '⚡ Quickstart (Recommended)', hint: 'Production defaults, minimal prompts' },
@@ -141,7 +142,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
141
142
  if (finalFramework === 'rails') defaultPort = '3000';
142
143
  if (finalFramework === 'go') defaultPort = '8080';
143
144
 
144
- let currentGitBranch = 'main';
145
+ currentGitBranch = 'main';
145
146
  try {
146
147
  currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
147
148
  } catch (e) {
@@ -149,7 +150,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
149
150
  }
150
151
 
151
152
  // 5. Ask for Managed Database (Only for Backend/Fullstack Frameworks)
152
- let needsDatabase = false;
153
+ needsDatabase = false;
153
154
  const isBackendFramework = ['node', 'nextjs', 'nuxt', 'python', 'django', 'rails', 'go'].includes(finalFramework);
154
155
 
155
156
  if (isBackendFramework) {
@@ -166,7 +167,7 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
166
167
  }
167
168
 
168
169
  // 6. Prompt Configuration Group
169
- const project = await group(
170
+ project = await group(
170
171
  {
171
172
  region: () =>
172
173
  select({
@@ -234,20 +235,17 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
234
235
  // 7. Map the user's choices and update the variables
235
236
  const cpu = project.size === 'small' ? '512' : '256';
236
237
  const memory = project.size === 'small' ? '1024' : '512';
237
-
238
- const baseCost = project.size === 'small' ? 35 : 25;
239
- const dbCost = needsDatabase ? 14 : 0;
240
- const totalCost = baseCost + dbCost;
241
-
242
238
  const computeTier = project.size === 'small' ? 'Small (0.5 vCPU, 1GB RAM)' : 'Micro (0.25 vCPU, 512MB RAM)';
243
- const estimatedCost = `~$${totalCost}.00 / month${needsDatabase ? ' (Includes Fargate + RDS PostgreSQL)' : ''}`;
239
+
240
+ const costs = estimateMonthlyCost({ cpu: parseInt(cpu), memory: parseInt(memory), hasDb: needsDatabase });
241
+ const estimatedCost = `~$${costs.totalMonthly} / month${needsDatabase ? ' (Includes Fargate + RDS PostgreSQL)' : ''}`;
244
242
 
245
243
  const healthCheckPath = project.healthCheckPath || '/';
246
244
  const desiredCount = project.desiredCount || '1';
247
245
  const deployBranch = project.branch || currentGitBranch;
248
246
  const buildDir = detectedFramework?.buildDir || 'dist';
249
247
 
250
- // 7.4 Check for conflicting CI boilerplate (Rails)
248
+ // 7.1 Check for conflicting CI boilerplate (Rails)
251
249
  if (finalFramework === 'rails') {
252
250
  const ciPath = path.join(targetDir, '.github', 'workflows', 'ci.yml');
253
251
  const dependabotPath = path.join(targetDir, '.github', 'dependabot.yml');
@@ -271,20 +269,6 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
271
269
  }
272
270
  }
273
271
 
274
- // 7.5. The Pre-Flight Cost Estimator
275
- // We explicitly ask for financial consent to eliminate AWS billing anxiety.
276
- if (!isHeadless) {
277
- console.log(''); // Add a blank line for visual pacing
278
- const costConsent = await confirm({
279
- message: color.yellow(`⚠️ Pre-Flight Check: This AWS architecture will cost ${estimatedCost}. Proceed with provisioning?`),
280
- initialValue: true,
281
- });
282
- if (!costConsent || typeof costConsent === 'symbol') {
283
- cancel('Deployment cancelled. No AWS resources were provisioned.');
284
- process.exit(0);
285
- }
286
- }
287
-
288
272
  // 8. Safely handle existing files (Backup and auto-prune)
289
273
  await handleExistingFiles(targetDir, isHeadless);
290
274
 
@@ -350,8 +334,10 @@ export async function mainStack({ isHeadless = false, headlessOptions = {} } = {
350
334
 
351
335
  const isGitInitialized = fsSync.existsSync(path.join(targetDir, '.git'));
352
336
 
353
- const cdStep = projectName === '.' ? '' : `1. cd ${projectName}\n `;
354
- const applyStep = `${cdStep}npx deploy-stack apply`;
337
+ const needsCd = projectName && projectName !== '.';
338
+ const applyStep = needsCd
339
+ ? `cd ${projectName} && npx deploy-stack apply`
340
+ : 'npx deploy-stack apply';
355
341
 
356
342
  const gitInstructions = isGitInitialized
357
343
  ? `git add . && git commit -m "chore: add AWS infrastructure and CI/CD" && git push`
@@ -0,0 +1,107 @@
1
+ import { note, confirm, isCancel, cancel } from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ // Cost benchmarks for AWS us-east-1 baseline (Fargate + ALB)
7
+ const PRICING_TABLE = {
8
+ fargate: {
9
+ cpuPerHour: 0.04048, // per vCPU hour
10
+ memoryPerHour: 0.004445 // per GB hour
11
+ },
12
+ alb: {
13
+ basePerHour: 0.0225, // ~$16.20/month
14
+ lcuPerHour: 0.008 // Baseline ~1 LCU (~$5.76/month)
15
+ },
16
+ rds: {
17
+ microPerHour: 0.016, // ~$11.68/mo for db.t4g.micro
18
+ storagePerMonth: 2.30 // 20GB gp3 storage baseline
19
+ }
20
+ };
21
+
22
+ // 1. Parse the local terraform files to extract the actual configuration
23
+ export function parseTerraformConfig(tfDir) {
24
+ const tfvarsPath = path.join(tfDir, 'terraform.tfvars');
25
+ let region = 'us-east-1';
26
+ let cpu = 256;
27
+ let memory = 512;
28
+ let framework = 'Application';
29
+
30
+ if (fs.existsSync(tfvarsPath)) {
31
+ const content = fs.readFileSync(tfvarsPath, 'utf-8');
32
+
33
+ // Use regex to pull values out of the HCL format
34
+ const regionMatch = content.match(/aws_region\s*=\s*"([^"]+)"/);
35
+ if (regionMatch) region = regionMatch[1];
36
+
37
+ const cpuMatch = content.match(/container_cpu\s*=\s*(\d+)/);
38
+ if (cpuMatch) cpu = parseInt(cpuMatch[1], 10);
39
+
40
+ const memoryMatch = content.match(/container_memory\s*=\s*(\d+)/);
41
+ if (memoryMatch) memory = parseInt(memoryMatch[1], 10);
42
+ }
43
+
44
+ // Check if database files exist
45
+ const hasDb = fs.existsSync(path.join(tfDir, 'rds.tf')) || fs.existsSync(path.join(tfDir, 'database.tf'));
46
+
47
+ return { framework, region, cpu, memory, hasDb };
48
+ }
49
+
50
+ // 2. Calculate itemized monthly costs based on task definition settings
51
+ export function estimateMonthlyCost({ cpu = 256, memory = 512, hasDb = false }) {
52
+ const vCpu = cpu / 1024;
53
+ const memGb = memory / 1024;
54
+ const hoursInMonth = 730;
55
+
56
+ const fargateCost = ((vCpu * PRICING_TABLE.fargate.cpuPerHour) +
57
+ (memGb * PRICING_TABLE.fargate.memoryPerHour)) * hoursInMonth;
58
+ const albCost = (PRICING_TABLE.alb.basePerHour + PRICING_TABLE.alb.lcuPerHour) * hoursInMonth;
59
+ const dbCost = hasDb ? (PRICING_TABLE.rds.microPerHour * hoursInMonth) + PRICING_TABLE.rds.storagePerMonth : 0;
60
+
61
+ const total = fargateCost + albCost + dbCost;
62
+
63
+ return {
64
+ fargateMonthly: fargateCost.toFixed(2),
65
+ albMonthly: albCost.toFixed(2),
66
+ dbMonthly: dbCost.toFixed(2),
67
+ totalMonthly: total.toFixed(2)
68
+ };
69
+ }
70
+
71
+ // 3. Render the terminal architecture visualization and requests confirmation
72
+ 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 });
75
+
76
+ const hourlyRate = (cost.totalMonthly / 730).toFixed(3); // 730 hours in a month
77
+
78
+ const treeOutput = [
79
+ `${pc.bold('Topology')} (${pc.cyan(region)}):`,
80
+ ` ${pc.gray('├──')} 🌐 ${pc.bold('ALB')} (Public Entry & Health: ${pc.green('200 OK')})`,
81
+ ` ${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)` : '',
84
+ '',
85
+ `${pc.bold('Est. Monthly Cost:')} ${pc.green(pc.bold(`~$${cost.totalMonthly}`))} ${pc.dim(`(ALB: $${cost.albMonthly}, Fargate: $${cost.fargateMonthly}${hasDb ? `, RDS: $${cost.dbMonthly}` : ''})`)}`,
86
+ ` ${pc.dim(`* Hourly billing: ~$${hourlyRate}/hr. Destroy anytime with "npx deploy-stack destroy --yes"`)}`
87
+ ].filter(Boolean).join('\n');
88
+
89
+ note(treeOutput, 'Cloud Infrastructure Pre-Flight Inspection');
90
+
91
+ // 4. Check if this is a dry run (print & exit) or full apply (prompt & proceed)
92
+ if (isDryRunFlag) {
93
+ return true;
94
+ }
95
+
96
+ const shouldProceed = await confirm({
97
+ message: 'Review completed. Provision this infrastructure to AWS now?',
98
+ initialValue: true
99
+ });
100
+
101
+ if (isCancel(shouldProceed) || !shouldProceed) {
102
+ cancel('Operation canceled. No infrastructure was created.');
103
+ process.exit(0);
104
+ }
105
+
106
+ return true;
107
+ }