deploy-stack 0.2.1 β†’ 0.2.3

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,6 +4,8 @@
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)
8
+
7
9
  ---
8
10
 
9
11
  ## The Problem
@@ -118,11 +120,12 @@ npx deploy-stack --no-telemetry
118
120
 
119
121
  ## πŸ—ΊοΈ Roadmap
120
122
 
121
- - [x] **POC:** Interactive CLI, ECS Fargate + ALB Terraform generation, GitHub Actions CI/CD, and Secrets sync.
122
- - [x] **MVP:** CloudFront distribution, Remote S3 State locking, OIDC security, and CLI configuration flows.
123
- - [ ] **v0.9:** Zero-config framework detection (auto-discovering Next.js, Express, and FastAPI via `package.json` / `requirements.txt`).
124
- - [ ] **v1.0 (Pro):** Automated RDS PostgreSQL provisioning, custom domain Route 53 / ACM SSL configuration, and environment workspaces (`staging` vs `prod`).
125
- - [ ] **v2.0 (Enterprise):** Private VPC subnets with NAT gateways, AWS WAF integration, and automated compliance scanning.
123
+ - [x] **Core MVP:** Interactive CLI, ECS Fargate + ALB Terraform generation, CI/CD, and Secrets sync.
124
+ - [x] **Production Readiness:** CloudFront distribution, S3 State locking, and OIDC security.
125
+ - [x] **Smart Experience:** Zero-config framework auto-discovery for Next.js, Express, and FastAPI.
126
+ - [ ] **State & Storage:** Automated RDS PostgreSQL provisioning and persistent volumes.
127
+ - [ ] **Advanced Networking:** Custom domains (Route 53), ACM SSL configuration, and environment workspaces (staging/prod).
128
+ - [ ] **Security Hardening:** Private VPC subnets with NAT gateways, and AWS WAF integration.
126
129
 
127
130
  ---
128
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,27 @@
14
14
  "type": "git",
15
15
  "url": "git+https://github.com/anton-codes-iac/deploy-stack.git"
16
16
  },
17
+ "homepage": "https://github.com/anton-codes-iac/deploy-stack#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/anton-codes-iac/deploy-stack/issues"
20
+ },
21
+ "keywords": [
22
+ "aws",
23
+ "terraform",
24
+ "github-actions",
25
+ "ci-cd",
26
+ "ecs",
27
+ "fargate",
28
+ "nextjs-deployment",
29
+ "infrastructure-as-code",
30
+ "oidc",
31
+ "deploy-stack",
32
+ "expressjs",
33
+ "fastapi",
34
+ "nextjs",
35
+ "static-site",
36
+ "nginx"
37
+ ],
17
38
  "license": "MIT",
18
39
  "dependencies": {
19
40
  "@aws-sdk/client-s3": "3.1115.0",
@@ -1,15 +1,15 @@
1
- import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
2
- import { S3Client, CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
3
1
  import fsSync from 'fs';
4
2
  import fs from 'fs/promises';
5
3
  import path from 'path';
6
- import { fileURLToPath } from 'url';
7
4
  import { intro, outro, group, text, select, spinner, cancel, confirm, log } from '@clack/prompts';
8
5
  import color from 'picocolors';
9
6
 
10
7
  import { checkDependency } from '../utils/system.js';
11
8
  import { detectFramework } from '../utils/detector.js';
12
9
  import { trackEvent } from '../core/telemetry.js';
10
+ import { getFrameworkWarning } from '../utils/warnings.js';
11
+ import { provisionStateBucket } from '../utils/aws.js';
12
+ import { generateTemplates } from '../utils/generator.js';
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
15
15
  const __dirname = path.dirname(__filename);
@@ -111,19 +111,6 @@ export async function mainStack() {
111
111
  placeholder: defaultPort,
112
112
  defaultValue: defaultPort,
113
113
  }),
114
- framework: () => {
115
- if (detectedFramework) return undefined;
116
-
117
- return select({
118
- message: 'Which framework preset should we configure?',
119
- options: [
120
- { value: 'node', label: 'Node.js / Express' },
121
- { value: 'nextjs', label: 'Next.js (Standalone)' },
122
- { value: 'python', label: 'Python FastAPI' },
123
- { value: 'static', label: 'Static Site (Gatsby, React, plain HTML via Nginx)' },
124
- ],
125
- });
126
- },
127
114
  size: () =>
128
115
  select({
129
116
  message: 'Select your Fargate compute size:',
@@ -200,138 +187,38 @@ export async function mainStack() {
200
187
  s.start('Provisioning infrastructure...');
201
188
 
202
189
  // 6. Provision S3 bucket for Terraform state & enable versioning
203
- const stsClient = new STSClient({ region: project.region });
204
- let awsAccountId;
190
+ let awsAccountId, stateBucketName;
205
191
  try {
206
- const { Account } = await stsClient.send(new GetCallerIdentityCommand({}));
207
- awsAccountId = Account;
192
+ const bucketData = await provisionStateBucket(project.region, actualProjectName);
193
+ awsAccountId = bucketData.awsAccountId;
194
+ stateBucketName = bucketData.stateBucketName;
208
195
  } catch (error) {
209
- s.stop('❌ Failed to authenticate with AWS.');
210
- console.error(color.red(`AWS Error: Ensure your credentials are valid. (${error.name})`));
211
-
212
- trackEvent('cli-error', { step: 'aws_sts_auth', error_code: error.name });
196
+ s.stop('❌ Failed to provision remote state or authenticate with AWS.');
197
+ console.error(color.red(`AWS Error: ${error.message}`));
198
+ trackEvent('cli-error', { step: 'aws_provisioning', error_code: error.name || 'UNKNOWN' });
213
199
  process.exit(1);
214
200
  }
215
201
 
216
- let stateBucketName = `${actualProjectName}-tfstate-${awsAccountId}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
217
- if (stateBucketName.length > 63) {
218
- stateBucketName = stateBucketName.substring(0, 63).replace(/-$/, '');
219
- }
220
-
221
- const s3Client = new S3Client({ region: project.region });
222
- try {
223
- await s3Client.send(new CreateBucketCommand({
224
- Bucket: stateBucketName,
225
- CreateBucketConfiguration: project.region === 'us-east-1' ? undefined : { LocationConstraint: project.region }
226
- }));
227
-
228
- await s3Client.send(new PutBucketVersioningCommand({
229
- Bucket: stateBucketName,
230
- VersioningConfiguration: { Status: 'Enabled' }
231
- }));
232
- } catch (error) {
233
- if (error.name !== 'BucketAlreadyOwnedByYou') {
234
- s.stop('❌ Failed to provision remote state.');
235
- console.error(color.red(`AWS S3 Error: ${error.message}`));
236
-
237
- trackEvent('cli-error', { step: 's3_bucket_creation', error_code: error.name });
238
- process.exit(1);
239
- }
240
- }
241
-
242
202
  s.message('Synthesizing Terraform templates...');
243
203
 
244
- // 7. Create project directories
245
- await fs.mkdir(targetDir, { recursive: true });
246
- await fs.mkdir(path.join(targetDir, 'terraform'), { recursive: true });
247
- await fs.mkdir(path.join(targetDir, '.github', 'workflows'), { recursive: true });
248
-
249
- // 8. Read the template files
250
- const tfMainPath = path.join(__dirname, '../../templates', 'terraform', 'main.tf');
251
- const tfNetworkPath = path.join(__dirname, '../../templates', 'terraform', 'network.tf');
252
- const tfSecretsPath = path.join(__dirname, '../../templates', 'terraform', 'secrets.tf');
253
- const tfOidcPath = path.join(__dirname, '../../templates', 'terraform', 'oidc.tf');
254
- const tfBackendPath = path.join(__dirname, '../../templates', 'terraform', 'backend.tf');
255
- const tfCloudfrontPath = path.join(__dirname, '../../templates', 'terraform', 'cloudfront.tf');
256
- const dockerTemplatePath = path.join(__dirname, '../../templates', 'docker', `${finalFramework}.Dockerfile`);
257
- const githubActionPath = path.join(__dirname, '../../templates', 'github', 'deploy.yml');
258
- const readmePath = path.join(__dirname, '../../templates', 'README.md');
259
- const gitignorePath = path.join(__dirname, '../../templates', '_gitignore');
260
-
261
- let tfMain = await fs.readFile(tfMainPath, 'utf-8');
262
- let tfNetwork = await fs.readFile(tfNetworkPath, 'utf-8');
263
- let tfSecrets = await fs.readFile(tfSecretsPath, 'utf-8');
264
- let tfOidc = await fs.readFile(tfOidcPath, 'utf-8');
265
- let tfBackend = await fs.readFile(tfBackendPath, 'utf-8');
266
- let tfCloudfront = await fs.readFile(tfCloudfrontPath, 'utf-8');
267
- let dockerContent = await fs.readFile(dockerTemplatePath, 'utf-8');
268
- let githubAction = await fs.readFile(githubActionPath, 'utf-8');
269
- let readmeContent = await fs.readFile(readmePath, 'utf-8');
270
- let gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
271
-
272
- // 9. Update the variables
273
- const injectVariables = (content) => {
274
- return content
275
- .replace(/{{PROJECT_NAME}}/g, actualProjectName)
276
- .replace(/{{REGION}}/g, project.region)
277
- .replace(/{{PORT}}/g, project.port)
278
- .replace(/{{CPU}}/g, cpu)
279
- .replace(/{{MEMORY}}/g, memory)
280
- .replace(/{{COMPUTE_TIER}}/g, computeTier)
281
- .replace(/{{ESTIMATED_COST}}/g, estimatedCost)
282
- .replace(/{{STATE_BUCKET}}/g, stateBucketName)
283
- .replace(/{{AWS_ACCOUNT_ID}}/g, awsAccountId)
284
- .replace(/{{HEALTH_CHECK_PATH}}/g, healthCheckPath)
285
- .replace(/{{DESIRED_COUNT}}/g, desiredCount)
286
- .replace(/{{DEPLOY_BRANCH}}/g, deployBranch);
287
- };
288
-
289
- tfMain = injectVariables(tfMain);
290
- tfNetwork = injectVariables(tfNetwork);
291
- tfSecrets = injectVariables(tfSecrets);
292
- tfOidc = injectVariables(tfOidc);
293
- tfBackend = injectVariables(tfBackend);
294
- tfCloudfront = injectVariables(tfCloudfront);
295
- dockerContent = injectVariables(dockerContent);
296
- githubAction = injectVariables(githubAction);
297
- readmeContent = injectVariables(readmeContent);
298
-
299
- // 10. Write the finalized files
300
- await fs.writeFile(path.join(targetDir, 'terraform', 'main.tf'), tfMain);
301
- await fs.writeFile(path.join(targetDir, 'terraform', 'network.tf'), tfNetwork);
302
- await fs.writeFile(path.join(targetDir, 'terraform', 'secrets.tf'), tfSecrets);
303
- await fs.writeFile(path.join(targetDir, 'terraform', 'oidc.tf'), tfOidc);
304
- await fs.writeFile(path.join(targetDir, 'terraform', 'backend.tf'), tfBackend);
305
- await fs.writeFile(path.join(targetDir, 'terraform', 'cloudfront.tf'), tfCloudfront);
306
- await fs.writeFile(path.join(targetDir, 'Dockerfile'), dockerContent);
307
- await fs.writeFile(path.join(targetDir, '.github', 'workflows', 'deploy.yml'), githubAction);
308
- await fs.writeFile(path.join(targetDir, 'README.md'), readmeContent);
309
- await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
310
-
311
- // 10.5. Ensure .gitignore exists and contains necessary Terraform ignores
312
- const targetGitignore = path.join(targetDir, '.gitignore');
313
- if (!fsSync.existsSync(targetGitignore)) {
314
- // No gitignore exists? Give them the full template (Terraform + Node + Python)
315
- await fs.writeFile(targetGitignore, gitignoreContent);
316
- } else {
317
- // File exists? Only inject the Terraform rules to prevent duplicates
318
- const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
319
-
320
- if (!existingGitignore.includes('terraform/.terraform/')) {
321
- const terraformIgnores = `
322
- # Added by deploy-stack (Terraform)
323
- terraform/.terraform/
324
- terraform/*.tfstate
325
- terraform/*.tfstate.backup
326
- terraform/.terraform.lock.hcl
327
- terraform/secret_keys.json
328
- terraform/.terraform.*
329
- `;
330
- await fs.appendFile(targetGitignore, '\n' + terraformIgnores);
331
- }
332
- }
204
+ // 7. Generate all templates and directories
205
+ await generateTemplates(targetDir, {
206
+ PROJECT_NAME: actualProjectName,
207
+ REGION: project.region,
208
+ PORT: project.port,
209
+ CPU: cpu,
210
+ MEMORY: memory,
211
+ COMPUTE_TIER: computeTier,
212
+ ESTIMATED_COST: estimatedCost,
213
+ STATE_BUCKET: stateBucketName,
214
+ AWS_ACCOUNT_ID: awsAccountId,
215
+ HEALTH_CHECK_PATH: healthCheckPath,
216
+ DESIRED_COUNT: desiredCount,
217
+ DEPLOY_BRANCH: deployBranch,
218
+ finalFramework: finalFramework
219
+ });
333
220
 
334
- // 11. Track the event in telemetry
221
+ // 8. Track the event in telemetry
335
222
  trackEvent('project_provisioned', {
336
223
  projectName: actualProjectName,
337
224
  framework: finalFramework,
@@ -344,27 +231,25 @@ export async function mainStack() {
344
231
 
345
232
  s.stop('Infrastructure provisioned successfully!');
346
233
 
347
- // 12. Provide the Outro, Framework Warnings and Next Steps
348
- let frameworkWarnings = '';
234
+ // 9. Provide the Outro, Framework Warnings and Next Steps
235
+ const frameworkWarnings = getFrameworkWarning(finalFramework);
349
236
 
350
- if (finalFramework === 'nextjs') {
351
- frameworkWarnings =
352
- color.bgYellow(color.black(' ⚠️ IMPORTANT: NEXT.JS SETUP REQUIRED ')) +
353
- color.yellow('\n You must modify your next.config file and create a health check route before deploying.') +
354
- color.yellow('\n See the "Critical Application Prerequisites" section in your README.md for copy-paste code.\n\n');
355
- }
237
+ const cdStep = projectName === '.' ? '' : `1. cd ${projectName}\n `;
238
+ const deployStepNum = projectName === '.' ? '1' : '2';
239
+ const gitStepNum = projectName === '.' ? '2' : '3';
356
240
 
357
241
  outro(`
358
242
  ${color.green('βœ… Project provisioned successfully!')}
243
+
244
+ ${frameworkWarnings}
359
245
 
360
- Next steps:
361
- 1. cd ${project.name}
362
- 2. Deploy infrastructure:
363
- cd terraform && terraform init && terraform apply
364
- 3. Push to GitHub:
365
- git init && git add . && git commit -m "Initial commit"
366
-
367
- ${color.cyan('Once deployed, Terraform will output your new https://*.cloudfront.net URL.')}
246
+ Next steps:
247
+ ${cdStep}${deployStepNum}. Deploy infrastructure:
248
+ cd terraform && terraform init && terraform apply
249
+ ${gitStepNum}. Push to GitHub:
250
+ git init && git add . && git commit -m "Initial commit"
251
+
252
+ ${color.cyan('Once deployed, Terraform will output your new https://*.cloudfront.net URL.')}
368
253
 
369
254
  ${color.magenta('πŸš€ Infrastructure ready! Need help or have feedback? Grab 15 mins with Anton:')}
370
255
  ${color.underline('https://calendly.com/anton-codes-iac/15min')}
@@ -0,0 +1,38 @@
1
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
2
+ import { S3Client, CreateBucketCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3';
3
+
4
+ export async function provisionStateBucket(region, projectName) {
5
+ const stsClient = new STSClient({ region });
6
+ let awsAccountId;
7
+
8
+ // 1. Get AWS Account ID
9
+ const { Account } = await stsClient.send(new GetCallerIdentityCommand({}));
10
+ awsAccountId = Account;
11
+
12
+ // 2. Format Bucket Name
13
+ let stateBucketName = `${projectName}-tfstate-${awsAccountId}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
14
+ if (stateBucketName.length > 63) {
15
+ stateBucketName = stateBucketName.substring(0, 63).replace(/-$/, '');
16
+ }
17
+
18
+ // 3. Create S3 Bucket & Enable Versioning
19
+ const s3Client = new S3Client({ region });
20
+ try {
21
+ await s3Client.send(new CreateBucketCommand({
22
+ Bucket: stateBucketName,
23
+ CreateBucketConfiguration: region === 'us-east-1' ? undefined : { LocationConstraint: region }
24
+ }));
25
+
26
+ await s3Client.send(new PutBucketVersioningCommand({
27
+ Bucket: stateBucketName,
28
+ VersioningConfiguration: { Status: 'Enabled' }
29
+ }));
30
+ } catch (error) {
31
+ // Ignore if the bucket already exists and is owned by the user
32
+ if (error.name !== 'BucketAlreadyOwnedByYou') {
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ return { awsAccountId, stateBucketName };
38
+ }
@@ -0,0 +1,56 @@
1
+ import fsSync from 'fs';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ export async function generateTemplates(targetDir, config) {
10
+ // 1. Create directories
11
+ await fs.mkdir(targetDir, { recursive: true });
12
+ await fs.mkdir(path.join(targetDir, 'terraform'), { recursive: true });
13
+ await fs.mkdir(path.join(targetDir, '.github', 'workflows'), { recursive: true });
14
+
15
+ // 2. Define paths
16
+ const templatesDir = path.join(__dirname, '../../templates');
17
+ const filesToProcess = [
18
+ { src: 'terraform/main.tf', dest: 'terraform/main.tf' },
19
+ { src: 'terraform/network.tf', dest: 'terraform/network.tf' },
20
+ { src: 'terraform/secrets.tf', dest: 'terraform/secrets.tf' },
21
+ { src: 'terraform/oidc.tf', dest: 'terraform/oidc.tf' },
22
+ { src: 'terraform/backend.tf', dest: 'terraform/backend.tf' },
23
+ { src: 'terraform/cloudfront.tf', dest: 'terraform/cloudfront.tf' },
24
+ { src: `docker/${config.finalFramework}.Dockerfile`, dest: 'Dockerfile' },
25
+ { src: 'github/deploy.yml', dest: '.github/workflows/deploy.yml' },
26
+ { src: 'README.md', dest: 'README.md' }
27
+ ];
28
+
29
+ // 3. Process standard files
30
+ for (const file of filesToProcess) {
31
+ let content = await fs.readFile(path.join(templatesDir, file.src), 'utf-8');
32
+
33
+ // Inject variables
34
+ for (const [key, value] of Object.entries(config)) {
35
+ content = content.replace(new RegExp(`{{${key}}}`, 'g'), value);
36
+ }
37
+
38
+ await fs.writeFile(path.join(targetDir, file.dest), content);
39
+ }
40
+
41
+ // 4. Create empty secrets file
42
+ await fs.writeFile(path.join(targetDir, 'terraform', 'secret_keys.json'), "[]");
43
+
44
+ // 5. Handle .gitignore appending cleanly
45
+ const targetGitignore = path.join(targetDir, '.gitignore');
46
+ const gitignoreContent = await fs.readFile(path.join(templatesDir, '_gitignore'), 'utf-8');
47
+
48
+ if (!fsSync.existsSync(targetGitignore)) {
49
+ await fs.writeFile(targetGitignore, gitignoreContent);
50
+ } else {
51
+ const existingGitignore = await fs.readFile(targetGitignore, 'utf-8');
52
+ if (!existingGitignore.includes('terraform/.terraform/')) {
53
+ await fs.appendFile(targetGitignore, '\n# Added by deploy-stack (Terraform)\nterraform/.terraform/\nterraform/*.tfstate\nterraform/*.tfstate.backup\nterraform/.terraform.lock.hcl\nterraform/secret_keys.json\nterraform/.terraform.*\n');
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,21 @@
1
+ import color from 'picocolors';
2
+
3
+ export function getFrameworkWarning(frameworkId) {
4
+ switch (frameworkId) {
5
+ case 'nextjs':
6
+ return (
7
+ color.bgYellow(color.black(' ⚠️ IMPORTANT: NEXT.JS SETUP REQUIRED ')) +
8
+ color.yellow('\n You must modify your next.config file and create a health check route before deploying.') +
9
+ color.yellow('\n See the "Critical Application Prerequisites" section in your README.md for copy-paste code.\n\n')
10
+ );
11
+ case 'node':
12
+ return (
13
+ color.bgYellow(color.black(' ⚠️ IMPORTANT: NODE.JS SETUP REQUIRED ')) +
14
+ color.yellow('\n 1. Ensure your package.json has a "start" script (e.g., "start": "node index.js").') +
15
+ color.yellow('\n 2. Your app must listen on 0.0.0.0 (not localhost) to receive traffic in Docker.\n\n')
16
+ );
17
+ // Python / FastAPI warnings can be added here easily!
18
+ default:
19
+ return '';
20
+ }
21
+ }
@@ -82,4 +82,12 @@ const nextConfig: NextConfig = {
82
82
  };
83
83
 
84
84
  export default nextConfig;
85
- ```
85
+ ```
86
+
87
+ ### 3. Container Network Binding (Node & Python)
88
+
89
+ When running inside a Docker container, your server must bind to all network interfaces (`0.0.0.0`), not just `localhost` or `127.0.0.1`. If you bind to localhost, the AWS Load Balancer will not be able to route traffic to your application.
90
+
91
+ Make sure your app is configured correctly:
92
+ * **Express.js:** `app.listen(port, '0.0.0.0', () => ...)`
93
+ * **FastAPI:** `uvicorn.run(app, host="0.0.0.0", port=8000)`
@@ -1,7 +1,22 @@
1
- FROM node:18-alpine
1
+ FROM node:20-alpine
2
+
3
+ # 1. Set production environment (optimizes Node and prevents dev dependencies)
4
+ ENV NODE_ENV=production
5
+
2
6
  WORKDIR /app
7
+
8
+ # 2. Copy only dependency files first to cache the npm install layer
3
9
  COPY package*.json ./
4
- RUN npm install
10
+
11
+ # 3. Use npm ci for strict, deterministic, and faster CI/CD installs
12
+ RUN npm ci
13
+
14
+ # 4. Copy the rest of the application source code
5
15
  COPY . .
16
+
17
+ # 5. DevSecOps best practice: do not run the container as root
18
+ USER node
19
+
6
20
  EXPOSE {{PORT}}
21
+
7
22
  CMD ["npm", "start"]