deploy-stack 0.3.3 → 0.5.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- ![deploy-stack CLI demonstration](./docs/demo.gif)
7
+ <!-- ![deploy-stack CLI demonstration](./docs/demo.gif) -->
8
8
 
9
9
  ---
10
10
 
@@ -25,13 +25,16 @@ You retain complete ownership of your infrastructure code without relying on bla
25
25
  ## ✨ Features
26
26
 
27
27
  * **Zero Vendor Lock-In:** Generates standard, clean `.tf` files. Modify, expand, or decouple them at any time.
28
- * **Framework Agnostic:** Tailored container presets for Next.js (standalone), Express.js, FastAPI, and custom Docker setups.
28
+ * **Framework Agnostic:** Tailored container presets for Next.js, Express.js, FastAPI, and **Zero-Config Static Sites** (React, Vue, SvelteKit, Astro, Vite).
29
+ * **Smart Git Integration:** Automatically detects your working branch (`main`, `master`, `develop`) and binds it directly to the generated GitHub Actions pipeline.
29
30
  * **Production-Grade Defaults:** Automatically provisions an Amazon ECS Fargate cluster fronted by an Application Load Balancer across multiple availability zones.
30
31
  * **Global Edge Acceleration:** Includes an integrated AWS CloudFront CDN distribution with SSL termination and optimized caching.
31
32
  * **Keyless, Zero-Secret CI/CD:** Uses AWS IAM OpenID Connect (OIDC) for automated GitHub Actions deployments—no long-lived AWS keys stored in GitHub Secrets.
32
33
  * **Remote State with Native S3 Locking:** Automatically creates an encrypted S3 state bucket utilizing modern native S3 concurrency locking.
33
34
  * **Built-in Secrets Sync:** Provides a dedicated CLI workflow to securely push local `.env` variables into AWS Secrets Manager and map them directly into containers at runtime.
34
35
  * **Non-Destructive:** Safely analyzes existing directories and prompts for confirmation before updating any files.
36
+ * **Zero-Config Detection:** Automatically resolves output directories for Vite, Astro, SvelteKit, CRA, and more.
37
+ * **Safe Teardown:** Completely remove all generated AWS resources and empty S3 state buckets with a single `destroy` command.
35
38
 
36
39
  ---
37
40
 
@@ -46,7 +49,7 @@ npx deploy-stack
46
49
  The interactive CLI will guide you through the setup:
47
50
  1. **Target Directory / Name:** (Type `.` to bootstrap your current directory)
48
51
  2. **Setup Mode:** (Choose **Quickstart** for sensible defaults, or **Advanced** for custom scaling and branch names)
49
- 3. **Zero-Config Detection:** The CLI automatically scans your `package.json` or `requirements.txt` to detect your framework (Next.js, Express, FastAPI, etc.) and dynamically configures your container port.
52
+ 3. **Zero-Config Detection:** The CLI automatically scans your project to detect your framework. For static sites, it intelligently discovers your build output directory (`dist`, `build`, `.output`, etc.) and dynamically configures the hardened Nginx container.
50
53
  4. **AWS Region & Compute Tier:** (Select your target region and Fargate size with live cost estimates)
51
54
 
52
55
  ---
@@ -80,6 +83,12 @@ npx deploy-stack secrets push .env.production
80
83
 
81
84
  ---
82
85
 
86
+ ## 🗑️ Infrastructure Teardown
87
+ To safely completely remove your ECS cluster, load balancers, and empty the remote S3 state bucket, run:
88
+ `npx deploy-stack destroy`
89
+
90
+ ---
91
+
83
92
  ## 📁 Generated File Structure
84
93
 
85
94
  Running the CLI generates a modular architecture tailored to your service:
@@ -104,8 +113,13 @@ your-project/
104
113
 
105
114
  ## 📦 Reference Implementations
106
115
 
107
- * **[Next.js Fullstack Reference App](https://github.com/anton-codes-iac/deploy-stack-nextjs-example):** A complete Next.js deployment showcasing the generated Terraform, CloudFront setup, and automated OIDC workflow.
108
-
116
+ * **[Next.js Fullstack App](https://github.com/anton-codes-iac/deploy-stack-nextjs-example):** A complete Next.js deployment showcasing the generated Terraform, CloudFront setup, and automated OIDC workflow.
117
+ * **[Express.js API](https://github.com/anton-codes-iac/deploy-stack-express-example):** A standard Node.js backend setup.
118
+ * **[Python FastAPI](https://github.com/anton-codes-iac/deploy-stack-fastapi-example):** A Python API demonstrating unprivileged port mapping.
119
+ * **[Vite / React SPA](https://github.com/anton-codes-iac/deploy-stack-vite-example):** Demonstrates SPA routing and `dist/` auto-detection.
120
+ * **[Create React App](https://github.com/anton-codes-iac/deploy-stack-cra-example):** Validates backward compatibility with legacy Webpack pipelines and `build/` auto-detection.
121
+ * **[Astro Static Site](https://github.com/anton-codes-iac/deploy-stack-astro-example):** Demonstrates modern static site generation (SSG).
122
+ * **[SvelteKit Application](https://github.com/anton-codes-iac/deploy-stack-svelte-example):** Demonstrates static adapter integration and custom output folder detection.
109
123
  ---
110
124
 
111
125
  ## 🛡️ Telemetry & Privacy
package/bin/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'path';
3
+ import { mainStack } from '../src/commands/init.js';
4
+ import { destroyStack } from '../src/commands/destroy.js';
3
5
  import { runDoctor } from '../src/commands/doctor.js';
4
6
  import { pushSecrets } from '../src/commands/secrets.js';
5
- import { mainStack } from '../src/commands/init.js';
6
7
 
7
8
  // 1. Extract the telemetry flag and set the environment variable
8
9
  const rawArgs = process.argv.slice(2);
@@ -22,6 +23,8 @@ if (args[0] === 'secrets' && args[1] === 'push') {
22
23
  pushSecrets(envFile, projectName).catch(console.error);
23
24
  } else if (args[0] === 'doctor') {
24
25
  runDoctor().catch(console.error);
26
+ } else if (args[0] === 'destroy') {
27
+ destroyStack().catch(console.error);
25
28
  } else {
26
29
  mainStack().catch(console.error);
27
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,77 @@
1
+ import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { intro, outro, confirm, spinner, cancel } from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ import { execSync } from 'child_process';
6
+ import { teardownStateBucket } from '../utils/aws.js';
7
+ import { checkDependency } from '../utils/system.js';
8
+ import { trackEvent, flushTelemetry } from '../core/telemetry.js';
9
+
10
+ export async function destroyStack() {
11
+ intro(color.bgRed(color.white(' deploy-stack destroy 🗑️ ')));
12
+
13
+ const tfDirPath = path.join(process.cwd(), 'terraform');
14
+ const backendFilePath = path.join(tfDirPath, 'backend.tf');
15
+
16
+ if (!fsSync.existsSync(backendFilePath)) {
17
+ console.error(color.red('✖ No terraform/backend.tf found in the current directory.'));
18
+ console.log(color.yellow('Are you in the root of a deploy-stack project?'));
19
+ process.exit(1);
20
+ }
21
+
22
+ const hasTerraform = await checkDependency('terraform');
23
+ if (!hasTerraform) {
24
+ console.error(color.red('✖ Terraform is not installed.'));
25
+ process.exit(1);
26
+ }
27
+
28
+ const proceed = await confirm({
29
+ message: color.red('⚠️ WARNING: This will permanently destroy all AWS resources associated with this project. Are you absolutely sure?'),
30
+ initialValue: false,
31
+ });
32
+
33
+ if (!proceed) {
34
+ cancel('Destruction cancelled. Your infrastructure is safe.');
35
+ process.exit(0);
36
+ }
37
+
38
+ const s = spinner();
39
+
40
+ // 1. Extract Bucket and Region from backend.tf
41
+ const backendContent = fsSync.readFileSync(backendFilePath, 'utf-8');
42
+ const bucketMatch = backendContent.match(/bucket\s*=\s*"([^"]+)"/);
43
+ const regionMatch = backendContent.match(/region\s*=\s*"([^"]+)"/);
44
+
45
+ const bucketName = bucketMatch ? bucketMatch[1] : null;
46
+ const region = regionMatch ? regionMatch[1] : 'us-east-1';
47
+
48
+ // 2. Execute Terraform Destroy
49
+ console.log(color.cyan('\nInitiating Terraform destroy (this may take a few minutes)...\n'));
50
+ try {
51
+ execSync('terraform destroy -auto-approve', { cwd: tfDirPath, stdio: 'inherit' });
52
+ } catch (error) {
53
+ console.error(color.red('\n✖ Terraform destroy failed. Please check the output above.'));
54
+ process.exit(1);
55
+ }
56
+
57
+ // 3. Clean up the S3 State Bucket
58
+ if (bucketName) {
59
+ s.start(`Emptying and deleting S3 state bucket: ${bucketName}...`);
60
+ try {
61
+ await teardownStateBucket(region, bucketName);
62
+ s.stop(`S3 bucket ${bucketName} successfully deleted.`);
63
+ } catch (error) {
64
+ s.stop(`❌ Failed to delete S3 bucket. You may need to delete it manually in the AWS Console.`);
65
+ console.error(color.red(`AWS Error: ${error.message}`));
66
+ }
67
+ }
68
+
69
+ trackEvent('project_destroyed', {
70
+ region,
71
+ bucket: bucketName,
72
+ success: true
73
+ });
74
+ await flushTelemetry();
75
+
76
+ outro(color.green('✅ Infrastructure successfully destroyed. Your AWS bill is safe.'));
77
+ }
@@ -3,6 +3,7 @@ import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import { intro, outro, group, text, select, spinner, cancel, confirm, log } from '@clack/prompts';
5
5
  import color from 'picocolors';
6
+ import { execSync } from 'child_process';
6
7
 
7
8
  import { checkDependency } from '../utils/system.js';
8
9
  import { detectFramework } from '../utils/detector.js';
@@ -88,6 +89,14 @@ export async function mainStack() {
88
89
  if (finalFramework === 'static') defaultPort = '8080';
89
90
  if (finalFramework === 'python') defaultPort = '8000';
90
91
 
92
+ // 2.8 Check current Git branch
93
+ let currentGitBranch = 'main';
94
+ try {
95
+ currentGitBranch = execSync('git symbolic-ref --short HEAD', { cwd: targetDir, stdio: 'pipe' }).toString().trim();
96
+ } catch (e) {
97
+ // Not a git repo yet, fallback to 'main'
98
+ }
99
+
91
100
  // 3. Prompt Configuration Group
92
101
  const project = await group(
93
102
  {
@@ -140,8 +149,8 @@ export async function mainStack() {
140
149
  if (setupType === 'quick') return undefined;
141
150
  return text({
142
151
  message: 'Primary Git deployment branch for CI/CD:',
143
- placeholder: 'main',
144
- defaultValue: 'main',
152
+ placeholder: currentGitBranch,
153
+ defaultValue: currentGitBranch,
145
154
  });
146
155
  },
147
156
  },
@@ -162,7 +171,9 @@ export async function mainStack() {
162
171
 
163
172
  const healthCheckPath = project.healthCheckPath || '/';
164
173
  const desiredCount = project.desiredCount || '1';
165
- const deployBranch = project.branch || 'main';
174
+ const deployBranch = project.branch || currentGitBranch;
175
+
176
+ const buildDir = detectedFramework?.buildDir || 'dist';
166
177
 
167
178
  // 5. Check for existing files that might be overwritten
168
179
  const dockerfilePath = path.join(targetDir, 'Dockerfile');
@@ -213,6 +224,7 @@ export async function mainStack() {
213
224
  HEALTH_CHECK_PATH: healthCheckPath,
214
225
  DESIRED_COUNT: desiredCount,
215
226
  DEPLOY_BRANCH: deployBranch,
227
+ BUILD_DIR: buildDir,
216
228
  finalFramework: finalFramework
217
229
  });
218
230
 
@@ -220,6 +232,7 @@ export async function mainStack() {
220
232
  trackEvent('project_provisioned', {
221
233
  projectName: actualProjectName,
222
234
  framework: finalFramework,
235
+ specific_framework: detectedFramework?.name || finalFramework,
223
236
  region: project.region,
224
237
  size: project.size,
225
238
  setup_mode: setupType,
@@ -230,7 +243,10 @@ export async function mainStack() {
230
243
  s.stop('Infrastructure provisioned successfully!');
231
244
 
232
245
  // 9. Provide the Outro, Framework Warnings and Next Steps
233
- const frameworkWarnings = getFrameworkWarning(finalFramework);
246
+ let frameworkWarnings = '';
247
+ if (!(finalFramework === 'static' && detectedFramework?.buildDir)) {
248
+ frameworkWarnings = getFrameworkWarning(finalFramework);
249
+ }
234
250
 
235
251
  const isGitInitialized = fsSync.existsSync(path.join(targetDir, '.git'));
236
252
 
@@ -240,7 +256,7 @@ export async function mainStack() {
240
256
 
241
257
  const gitInstructions = isGitInitialized
242
258
  ? `git add .\n git commit -m "chore: add AWS infrastructure and CI/CD"\n git push`
243
- : `git init\n git add .\n git commit -m "chore: add AWS infrastructure and CI/CD"\n git branch -M main\n git remote add origin https://github.com/your-username/your-repo.git\n git push -u origin main`;
259
+ : `git init\n git add .\n git commit -m "chore: add AWS infrastructure and CI/CD"\n git branch -M ${deployBranch}\n git remote add origin https://github.com/your-username/your-repo.git\n git push -u origin ${deployBranch}`;
244
260
 
245
261
  outro(`
246
262
  ${color.green('✅ Project provisioned successfully!')}
package/src/utils/aws.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
2
- import { S3Client, CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
2
+ import { S3Client, CreateBucketCommand, PutBucketVersioningCommand, PutBucketTaggingCommand } from '@aws-sdk/client-s3';
3
+ import { DeleteBucketCommand, ListObjectVersionsCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3";
3
4
 
4
5
  export async function provisionStateBucket(region, projectName) {
5
6
  const stsClient = new STSClient({ region });
@@ -23,6 +24,15 @@ export async function provisionStateBucket(region, projectName) {
23
24
  CreateBucketConfiguration: region === 'us-east-1' ? undefined : { LocationConstraint: region }
24
25
  }));
25
26
 
27
+ await s3Client.send(new PutBucketTaggingCommand({
28
+ Bucket: stateBucketName,
29
+ Tagging: {
30
+ TagSet: [
31
+ { Key: "ManagedBy", Value: "deploy-stack" }
32
+ ]
33
+ }
34
+ }));
35
+
26
36
  await s3Client.send(new PutBucketVersioningCommand({
27
37
  Bucket: stateBucketName,
28
38
  VersioningConfiguration: { Status: 'Enabled' }
@@ -35,4 +45,36 @@ export async function provisionStateBucket(region, projectName) {
35
45
  }
36
46
 
37
47
  return { awsAccountId, stateBucketName };
48
+ }
49
+
50
+ export async function teardownStateBucket(region, bucketName) {
51
+ const client = new S3Client({ region });
52
+
53
+ try {
54
+ // 1. Fetch all object versions and delete markers
55
+ const listCommand = new ListObjectVersionsCommand({ Bucket: bucketName });
56
+ const { Versions, DeleteMarkers } = await client.send(listCommand);
57
+
58
+ const objectsToDelete = [];
59
+ if (Versions) objectsToDelete.push(...Versions.map(v => ({ Key: v.Key, VersionId: v.VersionId })));
60
+ if (DeleteMarkers) objectsToDelete.push(...DeleteMarkers.map(v => ({ Key: v.Key, VersionId: v.VersionId })));
61
+
62
+ // 2. Delete all contents if any exist
63
+ if (objectsToDelete.length > 0) {
64
+ const deleteCommand = new DeleteObjectsCommand({
65
+ Bucket: bucketName,
66
+ Delete: { Objects: objectsToDelete }
67
+ });
68
+ await client.send(deleteCommand);
69
+ }
70
+
71
+ // 3. Delete the now-empty bucket
72
+ const deleteBucketCommand = new DeleteBucketCommand({ Bucket: bucketName });
73
+ await client.send(deleteBucketCommand);
74
+
75
+ return true;
76
+ } catch (error) {
77
+ if (error.name === 'NoSuchBucket') return true; // Already deleted
78
+ throw error;
79
+ }
38
80
  }
@@ -12,9 +12,19 @@ export function detectFramework(targetDir) {
12
12
  // Merge dependencies and devDependencies to check both
13
13
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
14
14
 
15
+ // Next.js & Express
15
16
  if (deps['next']) return { id: 'nextjs', name: 'Next.js' };
16
- if (deps['vite'] || deps['astro'] || deps['@sveltejs/kit'] || deps['gatsby'] || deps['react-scripts'] || deps['@vue/cli-service']) return { id: 'static', name: 'Static Site (Vite, Astro, React)' };
17
17
  if (deps['express']) return { id: 'node', name: 'Node.js / Express' };
18
+
19
+ // Static Site Generators & SPAs (with precise build directories)
20
+ if (deps['@sveltejs/kit']) return { id: 'static', name: 'SvelteKit', buildDir: 'build' };
21
+ if (deps['react-scripts']) return { id: 'static', name: 'Create React App', buildDir: 'build' };
22
+ if (deps['gatsby']) return { id: 'static', name: 'Gatsby', buildDir: 'public' };
23
+ if (deps['nuxt']) return { id: 'static', name: 'Nuxt', buildDir: '.output/public' };
24
+ if (deps['astro']) return { id: 'static', name: 'Astro', buildDir: 'dist' };
25
+ if (deps['vite']) return { id: 'static', name: 'Vite', buildDir: 'dist' };
26
+ if (deps['@vue/cli-service']) return { id: 'static', name: 'Vue.js', buildDir: 'dist' };
27
+ if (deps['@angular/cli']) return { id: 'static', name: 'Angular', buildDir: 'dist' };
18
28
  } catch (e) {
19
29
  // Silently fail if package.json is malformed
20
30
  }
@@ -103,6 +103,8 @@ env/
103
103
  node_modules/
104
104
  dist/
105
105
  build/
106
+ out/
107
+ .output/
106
108
  .cache/
107
109
  public/
108
110
  `;
@@ -25,10 +25,9 @@ export function getFrameworkWarning(frameworkId) {
25
25
  return (
26
26
  color.bgYellow(color.black(' ⚠️ IMPORTANT: STATIC SITE SETUP REQUIRED ')) +
27
27
  color.yellow('\n 1. Open your generated Dockerfile.') +
28
- color.yellow('\n 2. Ensure the "COPY --from=builder" command matches your framework:') +
29
- color.yellow('\n Vite / Astro: COPY --from=builder /app/dist /usr/share/nginx/html') +
30
- color.yellow('\n Create React App: COPY --from=builder /app/build /usr/share/nginx/html') +
31
- color.yellow('\n 3. Ensure your package.json has a "build" script (e.g., "vite build").\n\n')
28
+ color.yellow('\n 2. We defaulted your output folder to /app/dist.') +
29
+ color.yellow('\n 3. If your framework uses a different folder (like build/ or out/), change it in the COPY command.') +
30
+ color.yellow('\n 4. Ensure your package.json has a "build" script (e.g., "vite build").\n\n')
32
31
  );
33
32
  default:
34
33
  return '';
@@ -29,7 +29,7 @@ This infrastructure provisions a highly available Application Load Balancer (ALB
29
29
  ```
30
30
 
31
31
  3. **Automated CI/CD (Keyless via OIDC):**
32
- Push this repository to GitHub. Your deployment pipeline uses AWS IAM OpenID Connect (OIDC) to authenticate securely with temporary credentials—**no long-lived AWS secret keys are required in GitHub Secrets**. Every push to `main` will automatically build, package, and deploy your application.
32
+ Push this repository to GitHub. Your deployment pipeline uses AWS IAM OpenID Connect (OIDC) to authenticate securely with temporary credentials—**no long-lived AWS secret keys are required in GitHub Secrets**. Every push to `{{DEPLOY_BRANCH}}` will automatically build, package, and deploy your application.
33
33
 
34
34
  ### ⚠️ Troubleshooting: OIDC Provider Already Exists
35
35
  AWS only permits one GitHub Actions OIDC provider per AWS account. If `terraform apply` fails with an `EntityAlreadyExists` error regarding the OIDC provider, it indicates GitHub Actions was previously configured in this account.
@@ -48,13 +48,11 @@ Re-run `terraform apply` to link directly to your existing provider.
48
48
 
49
49
  If you are done testing and want to stop all AWS billing, you must destroy the infrastructure.
50
50
 
51
- Because our Terraform configuration is set to force-delete the ECR image repository (even if images are present), teardown is a single, clean command:
52
-
51
+ Run the automated teardown command from the root of your project:
53
52
  ```bash
54
- cd terraform
55
- terraform destroy
53
+ npx deploy-stack destroy
56
54
  ```
57
- *Type `yes` when prompted. This will permanently delete the Load Balancer, ECS cluster, log groups, and associated networking components.*
55
+ *Type `yes` when prompted. This will execute a safe Terraform teardown of your Load Balancer, ECS cluster, and networking components, followed by automatically emptying and deleting your remote S3 state bucket.*
58
56
 
59
57
  ## ⚠️ Critical Application Prerequisites
60
58
 
@@ -94,8 +92,8 @@ Make sure your app is configured correctly:
94
92
  * **Express.js:** `app.listen(port, '0.0.0.0', () => ...)`
95
93
  * **FastAPI:** `uvicorn.run(app, host="0.0.0.0", port=8000)`
96
94
 
97
- ### 4. Static Sites (Vite, Astro, React, Vue)
95
+ ### 4. Static Sites (Vite, Astro, React, Vue, SvelteKit)
98
96
 
99
97
  If you are deploying a static site, your application is served via a highly optimized, unprivileged Nginx container.
100
- 1. **Build Folder:** Different frameworks output compiled assets to different folders. Open your `Dockerfile` and ensure the `COPY --from=builder` command points to the correct folder (`dist`, `build`, or `out`).
101
- 2. **Health Checks:** You do not need to configure a custom `/health` route. Nginx will automatically return a `200 OK` when AWS pings the root `/` index page.
98
+ * **Zero-Config Build:** The CLI automatically detected your framework's output folder (`dist`, `build`, etc.) and pre-configured your Dockerfile.
99
+ * **Health Checks:** You do not need to configure a custom `/health` route. Nginx will automatically return a `200 OK` when AWS pings the root `/` index page.
@@ -13,9 +13,8 @@ RUN npm run build
13
13
  # STAGE 2: Serve with Hardened Nginx
14
14
  FROM nginx:alpine
15
15
 
16
- # ⚠️ CRITICAL: Adjust 'dist' to match your framework's output folder!
17
- # Vite/Astro = dist | Create React App/Gatsby = build | Next.js Static = out
18
- COPY --from=builder /app/dist /usr/share/nginx/html
16
+ # Adjusts BUILD_DIR to match your framework's output folder
17
+ COPY --from=builder /app/{{BUILD_DIR}} /usr/share/nginx/html
19
18
 
20
19
  # Inject custom Nginx configuration for unprivileged ports and SPA routing
21
20
  RUN echo "server {" > /etc/nginx/conf.d/default.conf && \