deploy-stack 0.2.2 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deploy-stack",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Provision production-ready AWS infrastructure and CI/CD pipelines in seconds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,18 +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';
13
-
14
- const __filename = fileURLToPath(import.meta.url);
15
- const __dirname = path.dirname(__filename);
10
+ import { getFrameworkWarning } from '../utils/warnings.js';
11
+ import { provisionStateBucket } from '../utils/aws.js';
12
+ import { generateTemplates } from '../utils/generator.js';
16
13
 
17
14
  export async function mainStack() {
18
15
  const startTime = Date.now();
@@ -111,19 +108,6 @@ export async function mainStack() {
111
108
  placeholder: defaultPort,
112
109
  defaultValue: defaultPort,
113
110
  }),
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
111
  size: () =>
128
112
  select({
129
113
  message: 'Select your Fargate compute size:',
@@ -200,138 +184,38 @@ export async function mainStack() {
200
184
  s.start('Provisioning infrastructure...');
201
185
 
202
186
  // 6. Provision S3 bucket for Terraform state & enable versioning
203
- const stsClient = new STSClient({ region: project.region });
204
- let awsAccountId;
187
+ let awsAccountId, stateBucketName;
205
188
  try {
206
- const { Account } = await stsClient.send(new GetCallerIdentityCommand({}));
207
- awsAccountId = Account;
189
+ const bucketData = await provisionStateBucket(project.region, actualProjectName);
190
+ awsAccountId = bucketData.awsAccountId;
191
+ stateBucketName = bucketData.stateBucketName;
208
192
  } 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 });
193
+ s.stop('❌ Failed to provision remote state or authenticate with AWS.');
194
+ console.error(color.red(`AWS Error: ${error.message}`));
195
+ trackEvent('cli-error', { step: 'aws_provisioning', error_code: error.name || 'UNKNOWN' });
213
196
  process.exit(1);
214
197
  }
215
198
 
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
199
  s.message('Synthesizing Terraform templates...');
243
200
 
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
- }
201
+ // 7. Generate all templates and directories
202
+ await generateTemplates(targetDir, {
203
+ PROJECT_NAME: actualProjectName,
204
+ REGION: project.region,
205
+ PORT: project.port,
206
+ CPU: cpu,
207
+ MEMORY: memory,
208
+ COMPUTE_TIER: computeTier,
209
+ ESTIMATED_COST: estimatedCost,
210
+ STATE_BUCKET: stateBucketName,
211
+ AWS_ACCOUNT_ID: awsAccountId,
212
+ HEALTH_CHECK_PATH: healthCheckPath,
213
+ DESIRED_COUNT: desiredCount,
214
+ DEPLOY_BRANCH: deployBranch,
215
+ finalFramework: finalFramework
216
+ });
333
217
 
334
- // 11. Track the event in telemetry
218
+ // 8. Track the event in telemetry
335
219
  trackEvent('project_provisioned', {
336
220
  projectName: actualProjectName,
337
221
  framework: finalFramework,
@@ -344,27 +228,25 @@ export async function mainStack() {
344
228
 
345
229
  s.stop('Infrastructure provisioned successfully!');
346
230
 
347
- // 12. Provide the Outro, Framework Warnings and Next Steps
348
- let frameworkWarnings = '';
231
+ // 9. Provide the Outro, Framework Warnings and Next Steps
232
+ const frameworkWarnings = getFrameworkWarning(finalFramework);
349
233
 
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
- }
234
+ const cdStep = projectName === '.' ? '' : `1. cd ${projectName}\n `;
235
+ const deployStepNum = projectName === '.' ? '1' : '2';
236
+ const gitStepNum = projectName === '.' ? '2' : '3';
356
237
 
357
238
  outro(`
358
239
  ${color.green('✅ Project provisioned successfully!')}
240
+
241
+ ${frameworkWarnings}
359
242
 
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.')}
243
+ Next steps:
244
+ ${cdStep}${deployStepNum}. Deploy infrastructure:
245
+ cd terraform && terraform init && terraform apply
246
+ ${gitStepNum}. Push to GitHub:
247
+ git init && git add . && git commit -m "Initial commit"
248
+
249
+ ${color.cyan('Once deployed, Terraform will output your new https://*.cloudfront.net URL.')}
368
250
 
369
251
  ${color.magenta('🚀 Infrastructure ready! Need help or have feedback? Grab 15 mins with Anton:')}
370
252
  ${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"]