@sembix/cli 1.9.0 → 1.10.1

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.
@@ -1,11 +1,11 @@
1
1
  import { input, select, confirm } from '@inquirer/prompts';
2
- import { awsRegions, awsAccountIdSchema, iamRoleArnSchema, acmCertArnSchema, route53ZoneIdSchema } from '../types.js';
2
+ import { awsRegions, awsAccountIdSchema, iamRoleArnSchema, acmCertArnSchema, route53ZoneIdSchema, secretsManagerArnSchema } from '../types.js';
3
3
  import * as ui from '../utils/ui.js';
4
4
  import chalk from 'chalk';
5
5
  import { promptIfMissing } from './prompt-helpers.js';
6
- /**
7
- * Helper function to display information before a prompt
8
- */
6
+ // ============================================================
7
+ // HELPERS
8
+ // ============================================================
9
9
  function explainField(title, description, example) {
10
10
  console.log();
11
11
  console.log(chalk.cyan('❓ ' + chalk.bold(title)));
@@ -15,88 +15,129 @@ function explainField(title, description, example) {
15
15
  }
16
16
  console.log();
17
17
  }
18
- export async function promptEnvironmentSetup(githubClient, providedEnvironmentName, providedRepo, partialConfig, isUpdate = false) {
19
- ui.section('Step 1: Repository Selection');
20
- console.log(chalk.dim('📦 Select the GitHub repository where your Sembix Studio deployment will be configured.'));
21
- console.log(chalk.dim(' This repository contains your deployment workflows and configuration files.'));
18
+ function hasAdvancedFields(partialConfig) {
19
+ if (!partialConfig)
20
+ return false;
21
+ const { security, tls, networking } = partialConfig;
22
+ return !!(security?.useCustomSecurityGroups ||
23
+ security?.useCustomIamPolicies ||
24
+ security?.customSecurityGroups ||
25
+ security?.customIamRoles ||
26
+ security?.workflowRunsKeyAlias ||
27
+ tls?.certificateArn ||
28
+ tls?.bffAlbInternal ||
29
+ tls?.bffInternalAlbCertificateArn ||
30
+ tls?.privateCaCertSecretArn ||
31
+ networking?.useCustomNetworking);
32
+ }
33
+ function getStepCount(mode) {
34
+ return mode === 'express' ? 4 : 8;
35
+ }
36
+ // ============================================================
37
+ // STEP: MODE SELECTION
38
+ // ============================================================
39
+ async function promptModeSelection(partialConfig) {
40
+ if (hasAdvancedFields(partialConfig)) {
41
+ ui.info('Advanced mode auto-selected (config contains advanced fields)');
42
+ console.log();
43
+ return 'advanced';
44
+ }
45
+ console.log();
46
+ console.log(chalk.dim(' Choose how you\'d like to configure your environment:'));
47
+ console.log();
48
+ return select({
49
+ message: 'Select setup mode:',
50
+ choices: [
51
+ {
52
+ name: 'Express (Recommended) — Quick setup with sensible defaults (~12 questions)',
53
+ value: 'express',
54
+ },
55
+ {
56
+ name: 'Advanced — Full control over all configuration options (~35+ questions)',
57
+ value: 'advanced',
58
+ },
59
+ ],
60
+ default: 'express',
61
+ });
62
+ }
63
+ // ============================================================
64
+ // STEP: REPOSITORY SELECTION
65
+ // ============================================================
66
+ async function promptRepository(ctx, stepNumber, providedRepo) {
67
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Repository Selection');
68
+ console.log(chalk.dim(' Select the GitHub repository where your Sembix Studio deployment will be configured.'));
69
+ console.log(chalk.dim(' This repository contains your deployment workflows and configuration files.'));
22
70
  console.log();
23
- // Step 1: Repository Selection
24
- let repositoryChoice;
25
- let owner;
26
- let repo;
27
71
  if (providedRepo) {
28
- // Repository provided via CLI argument - validate it exists
29
72
  const parts = providedRepo.split('/');
30
73
  if (parts.length !== 2) {
31
74
  ui.error('Invalid repository format. Use: owner/repo');
32
75
  process.exit(1);
33
76
  }
34
- [owner, repo] = parts;
35
- // Verify repository exists
77
+ const [owner, repo] = parts;
36
78
  try {
37
- await githubClient.getRepository(owner, repo);
38
- repositoryChoice = providedRepo;
39
- ui.info(`Using repository: ${ui.highlight(repositoryChoice)}`);
79
+ await ctx.githubClient.getRepository(owner, repo);
80
+ ui.info(`Using repository: ${ui.highlight(providedRepo)}`);
40
81
  console.log();
82
+ return { owner, repo };
41
83
  }
42
84
  catch {
43
85
  ui.error(`Repository '${providedRepo}' not found or not accessible.`);
44
86
  console.log();
45
- console.log(ui.dim('💡 Make sure:'));
46
- console.log(ui.dim(' The repository name is correct (owner/repo)'));
47
- console.log(ui.dim(' Your GitHub token has access to this repository'));
87
+ console.log(ui.dim(' Make sure:'));
88
+ console.log(ui.dim(' - The repository name is correct (owner/repo)'));
89
+ console.log(ui.dim(' - Your GitHub token has access to this repository'));
48
90
  process.exit(1);
49
91
  }
50
92
  }
93
+ const repos = await ctx.githubClient.listRepositories();
94
+ if (repos.length === 0) {
95
+ ui.error('No repositories found for your account.');
96
+ console.log();
97
+ console.log(ui.dim(' Tip: Make sure your GitHub token has access to the repositories you want to manage.'));
98
+ process.exit(1);
99
+ }
100
+ let repositoryChoice;
101
+ if (repos.length === 1) {
102
+ repositoryChoice = repos[0].full_name;
103
+ ui.info(`Using repository: ${ui.highlight(repositoryChoice)}`);
104
+ console.log();
105
+ }
51
106
  else {
52
- // Interactive repository selection
53
- const repos = await githubClient.listRepositories();
54
- if (repos.length === 0) {
55
- ui.error('No repositories found for your account.');
56
- console.log();
57
- console.log(ui.dim('💡 Tip: Make sure your GitHub token has access to the repositories you want to manage.'));
58
- process.exit(1);
59
- }
60
- if (repos.length === 1) {
61
- repositoryChoice = repos[0].full_name;
62
- ui.info(`Using repository: ${ui.highlight(repositoryChoice)}`);
63
- console.log();
64
- }
65
- else {
66
- repositoryChoice = await select({
67
- message: 'Select your deployment repository:',
68
- choices: repos.map(repo => ({
69
- name: repo.full_name,
70
- value: repo.full_name,
71
- })),
72
- });
73
- }
74
- [owner, repo] = repositoryChoice.split('/');
107
+ repositoryChoice = await select({
108
+ message: 'Select your deployment repository:',
109
+ choices: repos.map(r => ({
110
+ name: r.full_name,
111
+ value: r.full_name,
112
+ })),
113
+ });
75
114
  }
76
- // ============================================================
77
- // STEP 2: BASIC CONFIGURATION
78
- // ============================================================
79
- ui.section('Step 2: Basic Configuration');
80
- console.log(chalk.dim('⚙️ Configure the basic settings for your new Sembix Studio environment.'));
81
- console.log(chalk.dim(' These settings tell us where to deploy and how to connect to AWS.'));
115
+ const [owner, repo] = repositoryChoice.split('/');
116
+ return { owner, repo };
117
+ }
118
+ // ============================================================
119
+ // STEP: BASIC CONFIGURATION
120
+ // ============================================================
121
+ async function promptBasicConfig(ctx, stepNumber, owner, repo, providedEnvironmentName) {
122
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Basic Configuration');
123
+ console.log(chalk.dim(' Configure the basic settings for your Sembix Studio environment.'));
124
+ console.log(chalk.dim(' These settings tell us where to deploy and how to connect to AWS.'));
82
125
  console.log();
83
126
  // Environment Name
84
127
  let environmentName;
85
128
  if (providedEnvironmentName) {
86
- // Environment name provided via CLI argument - validate it
87
129
  if (!/^[a-z0-9-]+$/.test(providedEnvironmentName) || providedEnvironmentName.length < 3) {
88
130
  ui.error('Invalid environment name. Must be lowercase letters, numbers, and hyphens (min 3 chars).');
89
131
  process.exit(1);
90
132
  }
91
- // Check if environment already exists (only for create, not update)
92
- if (!isUpdate) {
93
- const envExists = await githubClient.environmentExists(owner, repo, providedEnvironmentName);
133
+ if (!ctx.isUpdate) {
134
+ const envExists = await ctx.githubClient.environmentExists(owner, repo, providedEnvironmentName);
94
135
  if (envExists) {
95
136
  ui.error(`Environment '${providedEnvironmentName}' already exists in ${owner}/${repo}`);
96
137
  console.log();
97
- console.log(ui.dim('💡 Tip:'));
98
- console.log(ui.dim(' Use a different environment name'));
99
- console.log(ui.dim(' Or update the existing environment with: sembix studio update'));
138
+ console.log(ui.dim(' Tip:'));
139
+ console.log(ui.dim(' - Use a different environment name'));
140
+ console.log(ui.dim(' - Or update the existing environment with: sembix studio update'));
100
141
  process.exit(1);
101
142
  }
102
143
  }
@@ -105,7 +146,6 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
105
146
  console.log();
106
147
  }
107
148
  else {
108
- // Interactive environment name prompt
109
149
  explainField('Environment Name', 'This is the name of your GitHub Actions environment. It should be unique and descriptive.\nUse lowercase letters, numbers, and hyphens only (no spaces or special characters).', 'client-abc-production or acme-staging');
110
150
  environmentName = await input({
111
151
  message: 'Environment name:',
@@ -122,7 +162,7 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
122
162
  });
123
163
  }
124
164
  // AWS Account ID
125
- const awsAccountId = await promptIfMissing(partialConfig?.awsAccountId, async () => {
165
+ const awsAccountId = await promptIfMissing(ctx.partialConfig?.awsAccountId, async () => {
126
166
  explainField('AWS Account ID', 'This is your 12-digit AWS account number where Sembix Studio will be deployed.\nYou can find this in the AWS Console by clicking on your account name in the top right.', '123456789012');
127
167
  return input({
128
168
  message: 'AWS Account ID (12 digits):',
@@ -137,7 +177,7 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
137
177
  });
138
178
  }, 'AWS Account ID');
139
179
  // AWS Region
140
- const awsRegion = await promptIfMissing(partialConfig?.awsRegion, async () => {
180
+ const awsRegion = await promptIfMissing(ctx.partialConfig?.awsRegion, async () => {
141
181
  explainField('AWS Region', 'Choose the AWS region where you want to deploy Sembix Studio.\nPick a region close to your users for better performance.', 'us-east-1 (Virginia) or eu-west-1 (Ireland)');
142
182
  return select({
143
183
  message: 'AWS Region:',
@@ -149,7 +189,7 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
149
189
  });
150
190
  }, 'AWS Region');
151
191
  // GitHub Actions Role ARN
152
- const customerRoleArn = await promptIfMissing(partialConfig?.customerRoleArn, async () => {
192
+ const customerRoleArn = await promptIfMissing(ctx.partialConfig?.customerRoleArn, async () => {
153
193
  explainField('GitHub Actions Deployment Role ARN', 'This is the IAM role that GitHub Actions will assume to deploy infrastructure to your AWS account.\nIt must have permissions to create and manage AWS resources (VPCs, ECS, RDS, etc.).', 'arn:aws:iam::123456789012:role/GitHubActionsDeployRole');
154
194
  return input({
155
195
  message: 'GitHub Actions Role ARN:',
@@ -164,7 +204,7 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
164
204
  });
165
205
  }, 'GitHub Actions Role ARN');
166
206
  // Terraform State Bucket
167
- const terraformStateBucket = await promptIfMissing(partialConfig?.terraformStateBucket, async () => {
207
+ const terraformStateBucket = await promptIfMissing(ctx.partialConfig?.terraformStateBucket, async () => {
168
208
  explainField('Terraform State S3 Bucket', 'This is the S3 bucket where Terraform will store its state file.\nThe state file keeps track of all the infrastructure Terraform creates.\nMake sure this bucket already exists in your AWS account.', 'my-terraform-state-bucket');
169
209
  return input({
170
210
  message: 'Terraform State S3 Bucket name:',
@@ -178,91 +218,110 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
178
218
  },
179
219
  });
180
220
  }, 'Terraform State Bucket');
181
- // ============================================================
182
- // STEP 3: DATABASE CONFIGURATION
183
- // ============================================================
184
- let databaseName;
185
- let databaseUser;
221
+ return { environmentName, awsAccountId, awsRegion, customerRoleArn, terraformStateBucket };
222
+ }
223
+ // ============================================================
224
+ // STEP: DATABASE CONFIGURATION
225
+ // ============================================================
226
+ async function promptDatabase(ctx, stepNumber) {
227
+ const { partialConfig } = ctx;
228
+ // Config always wins
186
229
  if (partialConfig?.database?.name && partialConfig?.database?.user) {
187
- // Both database fields provided - skip section
188
- databaseName = partialConfig.database.name;
189
- databaseUser = partialConfig.database.user;
190
- ui.info(`Using Database: ${ui.highlight(databaseName)} / ${ui.highlight(databaseUser)} (from config)`);
230
+ ui.info(`Using Database: ${ui.highlight(partialConfig.database.name)} / ${ui.highlight(partialConfig.database.user)} (from config)`);
191
231
  console.log();
232
+ return { name: partialConfig.database.name, user: partialConfig.database.user };
192
233
  }
193
- else {
194
- ui.section('Step 3: Database Configuration');
195
- console.log(chalk.dim('🗄️ Configure the PostgreSQL database settings for Sembix Studio.'));
196
- console.log(chalk.dim(' The database stores all your Studio data (workflows, users, etc.).'));
234
+ // Express mode: use defaults
235
+ if (ctx.mode === 'express') {
236
+ const name = partialConfig?.database?.name ?? 'sembix_studio';
237
+ const user = partialConfig?.database?.user ?? 'sembix_studio_user';
238
+ ui.info(`Using default Database: ${ui.highlight(name)} / ${ui.highlight(user)}`);
197
239
  console.log();
198
- // Database Name
199
- databaseName = await promptIfMissing(partialConfig?.database?.name, async () => {
200
- explainField('Database Name', 'The name of the PostgreSQL database that will be created for Sembix Studio.\nMust start with a letter and contain only letters, numbers, and underscores.', 'sembix_studio or studio_production');
201
- return input({
202
- message: 'Database name:',
203
- default: 'sembix_studio',
204
- validate: (value) => {
205
- if (!value)
206
- return 'Database name is required';
207
- if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
208
- return 'Must start with a letter and contain only letters, numbers, and underscores';
209
- }
210
- return true;
211
- },
212
- });
213
- }, 'Database name');
214
- // Database User
215
- databaseUser = await promptIfMissing(partialConfig?.database?.user, async () => {
216
- explainField('Database User', 'The PostgreSQL username that Sembix Studio will use to connect to the database.\nMust start with a letter and contain only letters, numbers, and underscores.', 'sembix_studio_user or studio_app');
217
- return input({
218
- message: 'Database user:',
219
- default: 'sembix_studio_user',
220
- validate: (value) => {
221
- if (!value)
222
- return 'Database user is required';
223
- if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
224
- return 'Must start with a letter and contain only letters, numbers, and underscores';
225
- }
226
- return true;
227
- },
228
- });
229
- }, 'Database user');
240
+ return { name, user };
241
+ }
242
+ // Advanced mode: prompt
243
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Database Configuration');
244
+ console.log(chalk.dim(' Configure the PostgreSQL database settings for Sembix Studio.'));
245
+ console.log(chalk.dim(' The database stores all your Studio data (workflows, users, etc.).'));
246
+ console.log();
247
+ const name = await promptIfMissing(partialConfig?.database?.name, async () => {
248
+ explainField('Database Name', 'The name of the PostgreSQL database that will be created for Sembix Studio.\nMust start with a letter and contain only letters, numbers, and underscores.', 'sembix_studio or studio_production');
249
+ return input({
250
+ message: 'Database name:',
251
+ default: 'sembix_studio',
252
+ validate: (value) => {
253
+ if (!value)
254
+ return 'Database name is required';
255
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
256
+ return 'Must start with a letter and contain only letters, numbers, and underscores';
257
+ }
258
+ return true;
259
+ },
260
+ });
261
+ }, 'Database name');
262
+ const user = await promptIfMissing(partialConfig?.database?.user, async () => {
263
+ explainField('Database User', 'The PostgreSQL username that Sembix Studio will use to connect to the database.\nMust start with a letter and contain only letters, numbers, and underscores.', 'sembix_studio_user or studio_app');
264
+ return input({
265
+ message: 'Database user:',
266
+ default: 'sembix_studio_user',
267
+ validate: (value) => {
268
+ if (!value)
269
+ return 'Database user is required';
270
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
271
+ return 'Must start with a letter and contain only letters, numbers, and underscores';
272
+ }
273
+ return true;
274
+ },
275
+ });
276
+ }, 'Database user');
277
+ return { name, user };
278
+ }
279
+ // ============================================================
280
+ // STEP: NETWORKING & INFRASTRUCTURE
281
+ // ============================================================
282
+ async function promptNetworking(ctx, stepNumber) {
283
+ // Express mode: use new VPC with all defaults
284
+ if (ctx.mode === 'express') {
285
+ ui.info('Using default Networking: New VPC (10.0.0.0/16, VPC endpoints enabled)');
286
+ console.log();
287
+ return {
288
+ enableVpcEndpoints: true,
289
+ useCustomNetworking: false,
290
+ vpcCidr: '10.0.0.0/16',
291
+ publicSubnetCidrs: '["10.0.0.0/24","10.0.3.0/24"]',
292
+ privateSubnetCidrs: '["10.0.1.0/24","10.0.2.0/24"]',
293
+ azCount: '2',
294
+ };
230
295
  }
231
- // ============================================================
232
- // STEP 4: NETWORKING & INFRASTRUCTURE
233
- // ============================================================
234
- ui.section('Step 4: Networking & Infrastructure');
235
- console.log(chalk.dim('🌐 Configure networking settings for your Sembix Studio deployment.'));
236
- console.log(chalk.dim(' You can either create a new VPC or use an existing one.'));
296
+ // Advanced mode: prompt
297
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Networking & Infrastructure');
298
+ console.log(chalk.dim(' Configure networking settings for your Sembix Studio deployment.'));
299
+ console.log(chalk.dim(' You can either create a new VPC or use an existing one.'));
237
300
  console.log();
238
301
  // VPC Endpoints
239
302
  explainField('VPC Endpoints', 'VPC endpoints allow your AWS resources to communicate with AWS services privately (without going through the internet).\nThis improves security and can reduce data transfer costs.\n\n' +
240
- chalk.green('Recommended: ') + 'Enable this unless you have a specific reason not to.', 'Choose Yes (recommended)');
303
+ chalk.green(' Recommended: ') + 'Enable this unless you have a specific reason not to.', 'Choose Yes (recommended)');
241
304
  const enableVpcEndpoints = await confirm({
242
305
  message: 'Enable VPC endpoints for AWS services?',
243
306
  default: true,
244
307
  });
245
308
  // Custom Networking
246
309
  console.log();
247
- console.log(chalk.cyan('' + chalk.bold('Use Existing VPC?')));
310
+ console.log(chalk.cyan(' ' + chalk.bold('Use Existing VPC?')));
248
311
  console.log(chalk.dim(' You have two options:'));
249
- console.log(chalk.dim(' ' + chalk.white('No (Create New)') + ' - We\'ll create a brand new VPC with all necessary networking (recommended for new deployments)'));
250
- console.log(chalk.dim(' ' + chalk.white('Yes (Use Existing)') + ' - You provide an existing VPC and subnets (for integrating with existing infrastructure)'));
312
+ console.log(chalk.dim(' - ' + chalk.white('No (Create New)') + ' - We\'ll create a brand new VPC with all necessary networking (recommended for new deployments)'));
313
+ console.log(chalk.dim(' - ' + chalk.white('Yes (Use Existing)') + ' - You provide an existing VPC and subnets (for integrating with existing infrastructure)'));
251
314
  console.log();
252
315
  const useCustomNetworking = await confirm({
253
316
  message: 'Do you want to use an existing VPC?',
254
317
  default: false,
255
318
  });
256
- let networking;
257
319
  if (useCustomNetworking) {
258
- // ============================================================
259
- // CUSTOM NETWORKING (Existing VPC)
260
- // ============================================================
261
320
  console.log();
262
- console.log(chalk.yellow('📝 Using Existing VPC'));
263
- console.log(chalk.dim(' You\'ll need to provide your VPC ID and subnet IDs from the AWS Console.'));
321
+ console.log(chalk.yellow(' Using Existing VPC'));
322
+ console.log(chalk.dim(' You\'ll need to provide your VPC ID and subnet IDs from the AWS Console.'));
264
323
  console.log();
265
- explainField('VPC ID', 'The ID of your existing Virtual Private Cloud.\nYou can find this in the AWS Console under VPC Your VPCs.', 'vpc-0123456789abcdef0');
324
+ explainField('VPC ID', 'The ID of your existing Virtual Private Cloud.\nYou can find this in the AWS Console under VPC > Your VPCs.', 'vpc-0123456789abcdef0');
266
325
  const customVpcId = await input({
267
326
  message: 'VPC ID:',
268
327
  validate: (value) => {
@@ -299,7 +358,7 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
299
358
  return true;
300
359
  },
301
360
  });
302
- networking = {
361
+ return {
303
362
  enableVpcEndpoints,
304
363
  useCustomNetworking: true,
305
364
  customVpcId,
@@ -307,108 +366,117 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
307
366
  customPrivateSubnetIds,
308
367
  };
309
368
  }
310
- else {
311
- // ============================================================
312
- // NEW VPC CREATION
313
- // ============================================================
314
- console.log();
315
- console.log(chalk.green('✨ Creating New VPC'));
316
- console.log(chalk.dim(' We\'ll create a new VPC with all necessary networking components.'));
317
- console.log(chalk.dim(' You can use the default values or customize them.'));
318
- console.log();
319
- explainField('VPC CIDR Block', 'This is the IP address range for your entire VPC (in CIDR notation).\nThe default (10.0.0.0/16) gives you 65,536 IP addresses, which is plenty for most deployments.\n\n' +
320
- chalk.green('💡 Tip: ') + 'Press Enter to use the default unless you have specific networking requirements.', '10.0.0.0/16 (default, recommended)');
321
- const vpcCidr = await input({
322
- message: 'VPC CIDR Block:',
323
- default: '10.0.0.0/16',
324
- validate: (value) => {
325
- if (!value)
326
- return 'VPC CIDR is required';
327
- if (!/^\d+\.\d+\.\d+\.\d+\/\d+$/.test(value)) {
328
- return 'Must be a valid CIDR block (e.g., 10.0.0.0/16)';
369
+ // New VPC Creation
370
+ console.log();
371
+ console.log(chalk.green(' Creating New VPC'));
372
+ console.log(chalk.dim(' We\'ll create a new VPC with all necessary networking components.'));
373
+ console.log(chalk.dim(' You can use the default values or customize them.'));
374
+ console.log();
375
+ explainField('VPC CIDR Block', 'This is the IP address range for your entire VPC (in CIDR notation).\nThe default (10.0.0.0/16) gives you 65,536 IP addresses, which is plenty for most deployments.\n\n' +
376
+ chalk.green(' Tip: ') + 'Press Enter to use the default unless you have specific networking requirements.', '10.0.0.0/16 (default, recommended)');
377
+ const vpcCidr = await input({
378
+ message: 'VPC CIDR Block:',
379
+ default: '10.0.0.0/16',
380
+ validate: (value) => {
381
+ if (!value)
382
+ return 'VPC CIDR is required';
383
+ if (!/^\d+\.\d+\.\d+\.\d+\/\d+$/.test(value)) {
384
+ return 'Must be a valid CIDR block (e.g., 10.0.0.0/16)';
385
+ }
386
+ return true;
387
+ },
388
+ });
389
+ explainField('Public Subnet CIDRs', 'IP ranges for public subnets (have internet access). Format as a JSON array.\nEach subnet will be created in a different availability zone for high availability.\n\n' +
390
+ chalk.green(' Tip: ') + 'Press Enter to use the defaults.', '["10.0.0.0/24","10.0.3.0/24"]');
391
+ const publicSubnetCidrs = await input({
392
+ message: 'Public Subnet CIDRs (JSON array):',
393
+ default: '["10.0.0.0/24","10.0.3.0/24"]',
394
+ validate: (value) => {
395
+ if (!value)
396
+ return 'Public subnet CIDRs are required';
397
+ try {
398
+ const parsed = JSON.parse(value);
399
+ if (!Array.isArray(parsed) || parsed.length === 0) {
400
+ return 'Must be a non-empty JSON array';
329
401
  }
330
402
  return true;
331
- },
332
- });
333
- explainField('Public Subnet CIDRs', 'IP ranges for public subnets (have internet access). Format as a JSON array.\nEach subnet will be created in a different availability zone for high availability.\n\n' +
334
- chalk.green('💡 Tip: ') + 'Press Enter to use the defaults.', '["10.0.0.0/24","10.0.3.0/24"]');
335
- const publicSubnetCidrs = await input({
336
- message: 'Public Subnet CIDRs (JSON array):',
337
- default: '["10.0.0.0/24","10.0.3.0/24"]',
338
- validate: (value) => {
339
- if (!value)
340
- return 'Public subnet CIDRs are required';
341
- try {
342
- const parsed = JSON.parse(value);
343
- if (!Array.isArray(parsed) || parsed.length === 0) {
344
- return 'Must be a non-empty JSON array';
345
- }
346
- return true;
347
- }
348
- catch {
349
- return 'Must be valid JSON array (e.g., ["10.0.0.0/24","10.0.3.0/24"])';
350
- }
351
- },
352
- });
353
- explainField('Private Subnet CIDRs', 'IP ranges for private subnets (no direct internet access). Format as a JSON array.\nThese are used for databases and application servers that don\'t need public access.\n\n' +
354
- chalk.green('💡 Tip: ') + 'Press Enter to use the defaults.', '["10.0.1.0/24","10.0.2.0/24"]');
355
- const privateSubnetCidrs = await input({
356
- message: 'Private Subnet CIDRs (JSON array):',
357
- default: '["10.0.1.0/24","10.0.2.0/24"]',
358
- validate: (value) => {
359
- if (!value)
360
- return 'Private subnet CIDRs are required';
361
- try {
362
- const parsed = JSON.parse(value);
363
- if (!Array.isArray(parsed) || parsed.length === 0) {
364
- return 'Must be a non-empty JSON array';
365
- }
366
- return true;
367
- }
368
- catch {
369
- return 'Must be valid JSON array (e.g., ["10.0.1.0/24","10.0.2.0/24"])';
403
+ }
404
+ catch {
405
+ return 'Must be valid JSON array (e.g., ["10.0.0.0/24","10.0.3.0/24"])';
406
+ }
407
+ },
408
+ });
409
+ explainField('Private Subnet CIDRs', 'IP ranges for private subnets (no direct internet access). Format as a JSON array.\nThese are used for databases and application servers that don\'t need public access.\n\n' +
410
+ chalk.green(' Tip: ') + 'Press Enter to use the defaults.', '["10.0.1.0/24","10.0.2.0/24"]');
411
+ const privateSubnetCidrs = await input({
412
+ message: 'Private Subnet CIDRs (JSON array):',
413
+ default: '["10.0.1.0/24","10.0.2.0/24"]',
414
+ validate: (value) => {
415
+ if (!value)
416
+ return 'Private subnet CIDRs are required';
417
+ try {
418
+ const parsed = JSON.parse(value);
419
+ if (!Array.isArray(parsed) || parsed.length === 0) {
420
+ return 'Must be a non-empty JSON array';
370
421
  }
371
- },
372
- });
373
- explainField('Availability Zones', 'AWS Availability Zones are isolated locations within a region.\nUsing multiple AZs provides high availability and fault tolerance.\n\n' +
374
- chalk.green('✓ Recommended: ') + '2 AZs is sufficient for most deployments.', '2 (recommended)');
375
- const azCount = await select({
376
- message: 'Number of Availability Zones:',
377
- choices: [
378
- { name: '2 (recommended - good balance of cost and availability)', value: '2' },
379
- { name: '3 (maximum availability)', value: '3' },
380
- ],
381
- default: '2',
382
- });
383
- networking = {
384
- enableVpcEndpoints,
385
- useCustomNetworking: false,
386
- vpcCidr,
387
- publicSubnetCidrs,
388
- privateSubnetCidrs,
389
- azCount,
422
+ return true;
423
+ }
424
+ catch {
425
+ return 'Must be valid JSON array (e.g., ["10.0.1.0/24","10.0.2.0/24"])';
426
+ }
427
+ },
428
+ });
429
+ explainField('Availability Zones', 'AWS Availability Zones are isolated locations within a region.\nUsing multiple AZs provides high availability and fault tolerance.\n\n' +
430
+ chalk.green(' Recommended: ') + '2 AZs is sufficient for most deployments.', '2 (recommended)');
431
+ const azCount = await select({
432
+ message: 'Number of Availability Zones:',
433
+ choices: [
434
+ { name: '2 (recommended - good balance of cost and availability)', value: '2' },
435
+ { name: '3 (maximum availability)', value: '3' },
436
+ ],
437
+ default: '2',
438
+ });
439
+ return {
440
+ enableVpcEndpoints,
441
+ useCustomNetworking: false,
442
+ vpcCidr,
443
+ publicSubnetCidrs,
444
+ privateSubnetCidrs,
445
+ azCount,
446
+ };
447
+ }
448
+ // ============================================================
449
+ // STEP: SECURITY & IAM
450
+ // ============================================================
451
+ async function promptSecurity(ctx, stepNumber) {
452
+ // Express mode: skip entirely
453
+ if (ctx.mode === 'express') {
454
+ ui.info('Using default Security: No custom security groups, no custom IAM roles');
455
+ console.log();
456
+ return {
457
+ useCustomSecurityGroups: false,
458
+ useCustomIamPolicies: false,
390
459
  };
391
460
  }
392
- // ============================================================
393
- // STEP 5: SECURITY & IAM (OPTIONAL)
394
- // ============================================================
395
- ui.section('Step 5: Security & IAM (Optional)');
396
- console.log(chalk.dim('🔒 Configure advanced security settings.'));
397
- console.log(chalk.dim(' These are optional. Most users can skip this section by pressing Enter.'));
461
+ // Advanced mode: prompt
462
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Security & IAM (Optional)');
463
+ console.log(chalk.dim(' Configure advanced security settings.'));
464
+ console.log(chalk.dim(' These are optional. Most users can skip this section by pressing Enter.'));
398
465
  console.log();
399
466
  // KMS Key
400
467
  explainField('Workflow Runs KMS Key (Optional)', 'AWS KMS (Key Management Service) key for encrypting workflow execution data.\nLeave blank to use AWS-managed encryption (recommended for most users).\n\n' +
401
- chalk.green('💡 Tip: ') + 'Press Enter to skip unless you have specific compliance requirements.', 'alias/my-kms-key or leave blank');
468
+ chalk.green(' Tip: ') + 'Press Enter to skip unless you have specific compliance requirements.', 'alias/my-kms-key or leave blank');
402
469
  const workflowRunsKeyAlias = await input({
403
470
  message: 'Workflow Runs KMS Key Alias (press Enter to skip):',
404
471
  default: '',
405
472
  });
406
473
  // Custom Security Groups
407
474
  console.log();
408
- console.log(chalk.cyan('' + chalk.bold('Custom Security Groups')));
475
+ console.log(chalk.cyan(' ' + chalk.bold('Custom Security Groups')));
409
476
  console.log(chalk.dim(' Security groups control network traffic to/from AWS resources.'));
410
- console.log(chalk.dim(' ' + chalk.white('No') + ' - We\'ll create new security groups with proper rules (recommended)'));
411
- console.log(chalk.dim(' ' + chalk.white('Yes') + ' - You provide existing security group IDs (for advanced users)'));
477
+ console.log(chalk.dim(' - ' + chalk.white('No') + ' - We\'ll create new security groups with proper rules (recommended)'));
478
+ console.log(chalk.dim(' - ' + chalk.white('Yes') + ' - You provide existing security group IDs (for advanced users)'));
479
+ ui.costSignal('Selecting Yes will ask for 8 security group IDs');
412
480
  console.log();
413
481
  const useCustomSecurityGroups = await confirm({
414
482
  message: 'Use custom security groups?',
@@ -417,8 +485,8 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
417
485
  let customSecurityGroups = undefined;
418
486
  if (useCustomSecurityGroups) {
419
487
  console.log();
420
- console.log(chalk.yellow('📝 Custom Security Groups'));
421
- console.log(chalk.dim(' Provide the security group IDs from AWS Console.'));
488
+ console.log(chalk.yellow(' Custom Security Groups'));
489
+ console.log(chalk.dim(' Provide the security group IDs from AWS Console.'));
422
490
  console.log();
423
491
  customSecurityGroups = {
424
492
  workflowEngine: await input({
@@ -457,10 +525,11 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
457
525
  }
458
526
  // Custom IAM Roles
459
527
  console.log();
460
- console.log(chalk.cyan('' + chalk.bold('Custom IAM Roles')));
528
+ console.log(chalk.cyan(' ' + chalk.bold('Custom IAM Roles')));
461
529
  console.log(chalk.dim(' IAM roles define permissions for AWS resources.'));
462
- console.log(chalk.dim(' ' + chalk.white('No') + ' - We\'ll create new IAM roles with proper permissions (recommended)'));
463
- console.log(chalk.dim(' ' + chalk.white('Yes') + ' - You provide existing IAM role ARNs (for advanced users)'));
530
+ console.log(chalk.dim(' - ' + chalk.white('No') + ' - We\'ll create new IAM roles with proper permissions (recommended)'));
531
+ console.log(chalk.dim(' - ' + chalk.white('Yes') + ' - You provide existing IAM role ARNs (for advanced users)'));
532
+ ui.costSignal('Selecting Yes will ask for 10 IAM role ARNs');
464
533
  console.log();
465
534
  const useCustomIamPolicies = await confirm({
466
535
  message: 'Use custom IAM roles?',
@@ -469,8 +538,8 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
469
538
  let customIamRoles = undefined;
470
539
  if (useCustomIamPolicies) {
471
540
  console.log();
472
- console.log(chalk.yellow('📝 Custom IAM Roles'));
473
- console.log(chalk.dim(' Provide the IAM role ARNs from AWS Console.'));
541
+ console.log(chalk.yellow(' Custom IAM Roles'));
542
+ console.log(chalk.dim(' Provide the IAM role ARNs from AWS Console.'));
474
543
  console.log();
475
544
  const validateRoleArn = (v) => !v || /^arn:aws:iam::\d{12}:role\/.+$/.test(v) || 'Must be valid IAM role ARN';
476
545
  customIamRoles = {
@@ -516,25 +585,38 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
516
585
  }),
517
586
  };
518
587
  }
519
- // ============================================================
520
- // STEP 6: DNS & TLS CONFIGURATION
521
- // ============================================================
522
- ui.section('Step 6: DNS & TLS Configuration');
523
- console.log(chalk.dim('🔐 Configure custom domains and SSL/TLS certificates for secure HTTPS access.'));
524
- console.log();
525
- console.log(chalk.dim('📌 ' + chalk.bold('Architecture Overview:')));
526
- console.log(chalk.dim(' Sembix Studio uses a certificate hierarchy for TLS:'));
527
- console.log(chalk.dim(' 1. Primary Certificate → Wildcard certificate used by all components'));
528
- console.log(chalk.dim(' 2. Component Overrides CloudFront, Public ALB, Internal ALB can use specific certificates'));
529
- console.log(chalk.dim(' If no override is provided, the component uses the primary certificate.'));
588
+ return {
589
+ workflowRunsKeyAlias: workflowRunsKeyAlias || undefined,
590
+ useCustomSecurityGroups,
591
+ customSecurityGroups,
592
+ useCustomIamPolicies,
593
+ customIamRoles,
594
+ };
595
+ }
596
+ // ============================================================
597
+ // STEP: DNS & TLS CONFIGURATION
598
+ // ============================================================
599
+ async function promptTls(ctx, stepNumber, awsRegion) {
600
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'DNS & TLS Configuration');
601
+ console.log(chalk.dim(' Configure custom domains and SSL/TLS certificates for secure HTTPS access.'));
530
602
  console.log();
531
- // Primary Certificate
532
- const certificateArn = await promptIfMissing(partialConfig?.tls?.certificateArn, async () => {
533
- explainField('Primary Certificate (Optional)', 'A wildcard ACM certificate used as the default for all TLS components.\nComponent-specific certificates below can override this for individual components.\n\n' +
534
- chalk.red('⚠️ IMPORTANT: ') + 'This certificate ' + chalk.bold('MUST') + ' be in the ' + chalk.bold('us-east-1') + ' region.\n' +
535
- ' (CloudFront may use this certificate as a fallback, which requires us-east-1)\n\n' +
536
- chalk.green('💡 Tip: ') + 'If you have a wildcard certificate (*.example.com), provide it here.\n' +
537
- ' Individual components can still use their own certificates.', 'arn:aws:acm:us-east-1:123456789012:certificate/wildcard-123... (or leave blank)');
603
+ const { partialConfig, mode } = ctx;
604
+ // Certificate hierarchy diagram (Advanced only shows override options)
605
+ if (mode === 'advanced') {
606
+ console.log(chalk.dim(' Certificate Hierarchy:'));
607
+ console.log(chalk.dim(' Primary Cert (used for all components)'));
608
+ console.log(chalk.dim(' |-- CloudFront (can override, must be us-east-1)'));
609
+ console.log(chalk.dim(' |-- Public ALB (can override)'));
610
+ console.log(chalk.dim(' +-- Private ALB (can override)'));
611
+ console.log();
612
+ }
613
+ // ─── Primary Certificate ───
614
+ let certificateArn = await promptIfMissing(partialConfig?.tls?.certificateArn, async () => {
615
+ explainField('Primary Certificate (Optional)', 'An ACM certificate used for all TLS components (CloudFront, public ALB, private ALB).\n' +
616
+ (mode === 'advanced' ? 'Component-specific certificates can override this in the sections below.\n\n' : '\n') +
617
+ chalk.red(' IMPORTANT: ') + 'This certificate ' + chalk.bold('MUST') + ' be in the ' + chalk.bold('us-east-1') + ' region.\n' +
618
+ ' (CloudFront requires us-east-1, so a single cert here must be in that region)\n\n' +
619
+ chalk.green(' Tip: ') + 'If you have a wildcard certificate (*.example.com), provide it here.', 'arn:aws:acm:us-east-1:123456789012:certificate/wildcard-123... (or leave blank)');
538
620
  return input({
539
621
  message: 'Primary Certificate ARN (must be in us-east-1, press Enter to skip):',
540
622
  default: '',
@@ -550,16 +632,21 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
550
632
  return true;
551
633
  },
552
634
  });
553
- }, 'Primary Certificate ARN');
554
- // CloudFront Domain
635
+ }, 'Primary Certificate ARN') || undefined;
636
+ const hasPrimaryCert = !!certificateArn;
637
+ // ─── CloudFront ───
638
+ ui.subsectionHeader('CloudFront');
639
+ // ─── CloudFront ───
640
+ ui.subsectionHeader('CloudFront');
555
641
  const cloudfrontDomain = await promptIfMissing(partialConfig?.tls?.cloudfrontDomain, async () => {
556
- explainField('CloudFront Domain', 'The custom domain name for your Studio UI (the website users will visit).\nThis domain must match the SSL certificate you provide below.\n\n' +
557
- chalk.green('💡 Example: ') + 'If your company is Acme Corp, you might use: studio.acme.com', 'studio.example.com or studio.acme.com');
642
+ explainField('CloudFront Domain (Optional)', 'A custom domain name for your Studio UI (the website users will visit).\nThis domain must match the SSL certificate you provide.\nLeave blank to use the default CloudFront domain (e.g. d1234abcdef.cloudfront.net).\n\n' +
643
+ chalk.green(' Example: ') + 'If your company is Acme Corp, you might use: studio.acme.com', 'studio.example.com or studio.acme.com (or leave blank for default CloudFront domain)');
558
644
  return input({
559
- message: 'CloudFront Domain:',
645
+ message: 'CloudFront Domain (press Enter to skip):',
646
+ default: '',
560
647
  validate: (value) => {
561
648
  if (!value)
562
- return 'CloudFront domain is required';
649
+ return true;
563
650
  if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(value)) {
564
651
  return 'Must be a valid domain name';
565
652
  }
@@ -567,129 +654,171 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
567
654
  },
568
655
  });
569
656
  }, 'CloudFront Domain');
570
- const hasPrimaryCert = !!certificateArn;
571
- // CloudFront Certificate
572
- const cloudfrontCertArn = await promptIfMissing(partialConfig?.tls?.cloudfrontCertArn, async () => {
573
- explainField(hasPrimaryCert ? 'CloudFront Certificate (Optional)' : 'CloudFront Certificate', 'The ACM certificate ARN for CloudFront.' + (hasPrimaryCert ? ' Overrides the primary certificate for CloudFront.' : '') + '\n\n' +
574
- chalk.red('⚠️ IMPORTANT: ') + 'This certificate ' + chalk.bold('MUST') + ' be in the ' + chalk.bold('us-east-1') + ' region.\n' +
575
- ' (This is a CloudFront requirement, even if your infrastructure is in a different region)\n\n' +
576
- chalk.green('💡 Tip: ') + (hasPrimaryCert
577
- ? 'Leave blank to use the primary certificate for CloudFront.'
578
- : 'Create the certificate in ACM in us-east-1, then copy the ARN here.'), 'arn:aws:acm:us-east-1:123456789012:certificate/abc123...' + (hasPrimaryCert ? ' (or leave blank)' : ''));
579
- return input({
580
- message: 'CloudFront Certificate ARN (must be in us-east-1' + (hasPrimaryCert ? ', press Enter to skip' : '') + '):',
581
- default: hasPrimaryCert ? '' : undefined,
582
- validate: (value) => {
583
- if (!value) {
584
- if (hasPrimaryCert)
585
- return true;
586
- return 'CloudFront certificate ARN is required (no primary certificate provided)';
587
- }
588
- const result = acmCertArnSchema.safeParse(value);
589
- if (!result.success)
590
- return 'Must be a valid ACM certificate ARN';
591
- if (!value.includes('us-east-1')) {
592
- return chalk.red('ERROR: ') + 'CloudFront certificate MUST be in us-east-1 region (CloudFront requirement)';
593
- }
594
- return true;
595
- },
596
- });
597
- }, 'CloudFront Certificate ARN');
598
- // Public ALB Certificate
599
- const bffAlbCertificateArn = await promptIfMissing(partialConfig?.tls?.bffAlbCertificateArn, async () => {
600
- explainField(hasPrimaryCert ? 'Public ALB Certificate (Optional)' : 'Public ALB Certificate', 'The ACM certificate ARN for the public-facing BFF API load balancer.' + (hasPrimaryCert ? '\nOverrides the primary certificate for the public ALB.' : '') + '\n\n' +
601
- chalk.green('💡 Tip: ') + (hasPrimaryCert
602
- ? 'Leave blank to use the primary certificate for the public ALB.'
603
- : 'This certificate should be in the same region as your infrastructure.'), 'arn:aws:acm:' + awsRegion + ':123456789012:certificate/def456...' + (hasPrimaryCert ? ' (or leave blank)' : ''));
604
- return input({
605
- message: 'Public ALB Certificate ARN' + (hasPrimaryCert ? ' (press Enter to skip):' : ':'),
606
- default: hasPrimaryCert ? '' : undefined,
607
- validate: (value) => {
608
- if (!value) {
609
- if (hasPrimaryCert)
657
+ // ─── Component certificate overrides (Advanced only) ───
658
+ // In express mode, the primary certificate is used for all components.
659
+ let cloudfrontCertArn;
660
+ let bffAlbCertificateArn;
661
+ if (mode === 'advanced') {
662
+ // CloudFront certificate override only if a custom domain was provided
663
+ // (the default CloudFront domain uses its own built-in certificate)
664
+ if (cloudfrontDomain || partialConfig?.tls?.cloudfrontCertArn) {
665
+ cloudfrontCertArn = await promptIfMissing(partialConfig?.tls?.cloudfrontCertArn, async () => {
666
+ explainField(hasPrimaryCert ? 'CloudFront Certificate (Optional)' : 'CloudFront Certificate', 'The ACM certificate ARN for CloudFront.' + (hasPrimaryCert ? ' Overrides the primary certificate for CloudFront.' : '') + '\n\n' +
667
+ chalk.red(' IMPORTANT: ') + 'This certificate ' + chalk.bold('MUST') + ' be in the ' + chalk.bold('us-east-1') + ' region.\n' +
668
+ ' (This is a CloudFront requirement, even if your infrastructure is in a different region)\n\n' +
669
+ chalk.green(' Tip: ') + (hasPrimaryCert
670
+ ? 'Leave blank to use the primary certificate for CloudFront.'
671
+ : 'Create the certificate in ACM in us-east-1, then copy the ARN here.'), 'arn:aws:acm:us-east-1:123456789012:certificate/abc123...' + (hasPrimaryCert ? ' (or leave blank)' : ''));
672
+ return input({
673
+ message: 'CloudFront Certificate ARN (must be in us-east-1' + (hasPrimaryCert ? ', press Enter to skip' : '') + '):',
674
+ default: hasPrimaryCert ? '' : undefined,
675
+ validate: (value) => {
676
+ if (!value) {
677
+ if (hasPrimaryCert)
678
+ return true;
679
+ return 'CloudFront certificate ARN is required (no primary certificate provided)';
680
+ }
681
+ const result = acmCertArnSchema.safeParse(value);
682
+ if (!result.success)
683
+ return 'Must be a valid ACM certificate ARN';
684
+ if (!value.includes('us-east-1')) {
685
+ return chalk.red('ERROR: ') + 'CloudFront certificate MUST be in us-east-1 region (CloudFront requirement)';
686
+ }
610
687
  return true;
611
- return 'Public ALB certificate ARN is required (no primary certificate provided)';
612
- }
613
- const result = acmCertArnSchema.safeParse(value);
614
- if (!result.success)
615
- return 'Must be a valid ACM certificate ARN';
616
- return true;
617
- },
618
- });
619
- }, 'Public ALB Certificate ARN');
620
- // BFF ALB Internal - only prompt when a BFF ALB certificate is available
621
- const hasBffAlbCert = !!(bffAlbCertificateArn || certificateArn);
622
- let bffAlbInternal;
623
- if (hasBffAlbCert) {
624
- bffAlbInternal = partialConfig?.tls?.bffAlbInternal ?? await (async () => {
625
- console.log();
626
- console.log(chalk.cyan('❓ ' + chalk.bold('Force BFF ALB Internal')));
627
- console.log(chalk.dim(' When enabled, the BFF ALB will be internal even though a certificate is provided.'));
628
- console.log(chalk.dim(' Use this for private DNS with ACM Private CA configurations.'));
629
- console.log();
630
- return confirm({
631
- message: 'Force BFF ALB to be internal? (for private DNS with ACM Private CA)',
632
- default: false,
688
+ },
689
+ });
690
+ }, 'CloudFront Certificate ARN');
691
+ }
692
+ // Public ALB certificate override
693
+ ui.subsectionHeader('Public ALB');
694
+ bffAlbCertificateArn = await promptIfMissing(partialConfig?.tls?.bffAlbCertificateArn, async () => {
695
+ explainField(hasPrimaryCert ? 'Public ALB Certificate (Optional)' : 'Public ALB Certificate', 'The ACM certificate ARN for the public-facing BFF API load balancer.' + (hasPrimaryCert ? '\nOverrides the primary certificate for the public ALB.' : '') + '\n\n' +
696
+ chalk.green(' Tip: ') + (hasPrimaryCert
697
+ ? 'Leave blank to use the primary certificate for the public ALB.'
698
+ : 'This certificate should be in the same region as your infrastructure.'), 'arn:aws:acm:' + awsRegion + ':123456789012:certificate/def456...' + (hasPrimaryCert ? ' (or leave blank)' : ''));
699
+ return input({
700
+ message: 'Public ALB Certificate ARN' + (hasPrimaryCert ? ' (press Enter to skip):' : ':'),
701
+ default: hasPrimaryCert ? '' : undefined,
702
+ validate: (value) => {
703
+ if (!value) {
704
+ if (hasPrimaryCert)
705
+ return true;
706
+ return 'Public ALB certificate ARN is required (no primary certificate provided)';
707
+ }
708
+ const result = acmCertArnSchema.safeParse(value);
709
+ if (!result.success)
710
+ return 'Must be a valid ACM certificate ARN';
711
+ return true;
712
+ },
633
713
  });
634
- })();
714
+ }, 'Public ALB Certificate ARN');
715
+ }
716
+ else {
717
+ // Express: use config overrides if present, otherwise primary cert covers everything
718
+ cloudfrontCertArn = partialConfig?.tls?.cloudfrontCertArn;
719
+ bffAlbCertificateArn = partialConfig?.tls?.bffAlbCertificateArn;
635
720
  }
636
- // BFF ALB Ingress CIDR Blocks - only prompt if bffAlbInternal is true
721
+ // BFF ALB Internal + Ingress CIDRs (Advanced only)
722
+ let bffAlbInternal;
637
723
  let bffAlbIngressCidrBlocks;
638
- if (bffAlbInternal) {
639
- if (partialConfig?.tls?.bffAlbIngressCidrBlocks?.length) {
640
- bffAlbIngressCidrBlocks = partialConfig.tls.bffAlbIngressCidrBlocks;
641
- ui.info(`Using BFF ALB Ingress CIDRs: ${ui.highlight(bffAlbIngressCidrBlocks.join(', '))} (from config)`);
642
- console.log();
724
+ if (mode === 'advanced') {
725
+ const hasBffAlbCert = !!(bffAlbCertificateArn || certificateArn);
726
+ if (hasBffAlbCert) {
727
+ bffAlbInternal = partialConfig?.tls?.bffAlbInternal ?? await (async () => {
728
+ console.log();
729
+ console.log(chalk.cyan(' ' + chalk.bold('Force BFF ALB Internal')));
730
+ console.log(chalk.dim(' When enabled, the BFF ALB will be internal even though a certificate is provided.'));
731
+ console.log(chalk.dim(' Use this for private DNS with ACM Private CA configurations.'));
732
+ console.log();
733
+ return confirm({
734
+ message: 'Force BFF ALB to be internal? (for private DNS with ACM Private CA)',
735
+ default: false,
736
+ });
737
+ })();
643
738
  }
644
- else {
645
- explainField('BFF ALB Ingress CIDR Blocks (Optional)', 'Restrict which CIDR blocks can reach the internal BFF ALB.\nLeave blank to fall back to VPC CIDR (Terraform default).', '10.0.0.0/8,172.16.0.0/12');
646
- const cidrInput = await input({
647
- message: 'CIDR blocks for internal BFF ALB ingress (comma-separated, press Enter to skip):',
739
+ if (bffAlbInternal) {
740
+ if (partialConfig?.tls?.bffAlbIngressCidrBlocks?.length) {
741
+ bffAlbIngressCidrBlocks = partialConfig.tls.bffAlbIngressCidrBlocks;
742
+ ui.info(`Using BFF ALB Ingress CIDRs: ${ui.highlight(bffAlbIngressCidrBlocks.join(', '))} (from config)`);
743
+ console.log();
744
+ }
745
+ else {
746
+ explainField('BFF ALB Ingress CIDR Blocks (Optional)', 'Restrict which CIDR blocks can reach the internal BFF ALB.\nLeave blank to fall back to VPC CIDR (Terraform default).', '10.0.0.0/8,172.16.0.0/12');
747
+ const cidrInput = await input({
748
+ message: 'CIDR blocks for internal BFF ALB ingress (comma-separated, press Enter to skip):',
749
+ default: '',
750
+ validate: (value) => {
751
+ if (!value)
752
+ return true;
753
+ const cidrs = value.split(',').map(c => c.trim());
754
+ const cidrRegex = /^\d+\.\d+\.\d+\.\d+\/\d+$/;
755
+ for (const cidr of cidrs) {
756
+ if (!cidrRegex.test(cidr)) {
757
+ return `Invalid CIDR block: ${cidr}`;
758
+ }
759
+ }
760
+ return true;
761
+ },
762
+ });
763
+ if (cidrInput) {
764
+ bffAlbIngressCidrBlocks = cidrInput.split(',').map(c => c.trim());
765
+ }
766
+ }
767
+ }
768
+ }
769
+ // ─── Private Services ALB ─── (Advanced only)
770
+ let bffInternalAlbCertificateArn;
771
+ let privateCaCertSecretArn;
772
+ if (mode === 'advanced') {
773
+ ui.subsectionHeader('Private Services ALB');
774
+ bffInternalAlbCertificateArn = await promptIfMissing(partialConfig?.tls?.bffInternalAlbCertificateArn, async () => {
775
+ explainField('Private Services ALB Certificate (Optional)', 'The ACM certificate ARN for the private services load balancer.\nThis ALB handles internal service-to-service traffic (e.g. workspace runtime, websockets).\nOverrides the primary certificate for this ALB.\n\n' +
776
+ chalk.green(' Tip: ') + 'Leave blank to use the primary certificate.', 'arn:aws:acm:' + awsRegion + ':123456789012:certificate/ghi789... (or leave blank)');
777
+ return input({
778
+ message: 'Private Services ALB Certificate ARN (press Enter to skip):',
648
779
  default: '',
649
780
  validate: (value) => {
650
781
  if (!value)
651
782
  return true;
652
- const cidrs = value.split(',').map(c => c.trim());
653
- const cidrRegex = /^\d+\.\d+\.\d+\.\d+\/\d+$/;
654
- for (const cidr of cidrs) {
655
- if (!cidrRegex.test(cidr)) {
656
- return `Invalid CIDR block: ${cidr}`;
657
- }
658
- }
783
+ const result = acmCertArnSchema.safeParse(value);
784
+ if (!result.success)
785
+ return 'Must be a valid ACM certificate ARN';
659
786
  return true;
660
787
  },
661
788
  });
662
- if (cidrInput) {
663
- bffAlbIngressCidrBlocks = cidrInput.split(',').map(c => c.trim());
664
- }
665
- }
789
+ }, 'Private Services ALB Certificate ARN') || undefined;
790
+ privateCaCertSecretArn = await promptIfMissing(partialConfig?.tls?.privateCaCertSecretArn, async () => {
791
+ explainField('Private CA Certificate Secret (Optional)', 'If either load balancer uses an ACM Private CA certificate, services need the CA\nroot certificate to trust it. Provide the Secrets Manager ARN that holds the PEM file.', 'arn:aws:secretsmanager:' + awsRegion + ':123456789012:secret:my-private-ca-pem-AbCdEf');
792
+ return input({
793
+ message: 'Private CA Cert Secret ARN (press Enter to skip):',
794
+ default: '',
795
+ validate: (value) => {
796
+ if (!value)
797
+ return true;
798
+ const result = secretsManagerArnSchema.safeParse(value);
799
+ if (!result.success)
800
+ return 'Must be a valid AWS Secrets Manager ARN';
801
+ return true;
802
+ },
803
+ });
804
+ }, 'Private CA Cert Secret ARN') || undefined;
805
+ }
806
+ else {
807
+ // Express: use config values if present
808
+ bffInternalAlbCertificateArn = partialConfig?.tls?.bffInternalAlbCertificateArn;
809
+ privateCaCertSecretArn = partialConfig?.tls?.privateCaCertSecretArn;
666
810
  }
667
- // Internal ALB Certificate
668
- const bffInternalAlbCertificateArn = await promptIfMissing(partialConfig?.tls?.bffInternalAlbCertificateArn, async () => {
669
- explainField('Internal ALB Certificate (Optional)', 'The ACM certificate ARN for the internal BFF API load balancer.\nOverrides the primary certificate for internal communication.\n\n' +
670
- chalk.green('💡 Tip: ') + 'Leave blank to use the primary certificate for internal ALB.', 'arn:aws:acm:' + awsRegion + ':123456789012:certificate/ghi789... (or leave blank)');
811
+ // ─── DNS ───
812
+ ui.subsectionHeader('DNS');
813
+ const hostedZoneId = await promptIfMissing(partialConfig?.tls?.hostedZoneId, async () => {
814
+ explainField('Route53 Hosted Zone ID (Optional)', 'The ID of your Route53 hosted zone where DNS records will be automatically created.\nYou can find this in the AWS Console under Route53 > Hosted zones.\nLeave blank if not using public DNS (e.g. internal ALB with ACM Private CA).\n\n' +
815
+ chalk.green(' Tip: ') + 'The hosted zone should manage the domain you\'re using (e.g., example.com).', 'Z1234567890ABC (or leave blank)');
671
816
  return input({
672
- message: 'Internal ALB Certificate ARN (press Enter to skip):',
817
+ message: 'Route53 Hosted Zone ID (press Enter to skip):',
673
818
  default: '',
674
819
  validate: (value) => {
675
820
  if (!value)
676
821
  return true;
677
- const result = acmCertArnSchema.safeParse(value);
678
- if (!result.success)
679
- return 'Must be a valid ACM certificate ARN';
680
- return true;
681
- },
682
- });
683
- }, 'Internal ALB Certificate ARN');
684
- // Hosted Zone ID
685
- const hostedZoneId = await promptIfMissing(partialConfig?.tls?.hostedZoneId, async () => {
686
- explainField('Route53 Hosted Zone ID', 'The ID of your Route53 hosted zone where DNS records will be automatically created.\nYou can find this in the AWS Console under Route53 → Hosted zones.\n\n' +
687
- chalk.green('💡 Tip: ') + 'The hosted zone should manage the domain you\'re using (e.g., example.com).', 'Z1234567890ABC');
688
- return input({
689
- message: 'Route53 Hosted Zone ID:',
690
- validate: (value) => {
691
- if (!value)
692
- return 'Hosted Zone ID is required';
693
822
  const result = route53ZoneIdSchema.safeParse(value);
694
823
  if (!result.success)
695
824
  return 'Must be a valid Route53 hosted zone ID (starts with Z)';
@@ -697,37 +826,52 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
697
826
  },
698
827
  });
699
828
  }, 'Route53 Hosted Zone ID');
700
- // ============================================================
701
- // STEP 7: FEATURE CONFIGURATION
702
- // ============================================================
703
- ui.section('Step 7: Feature Configuration');
704
- console.log(chalk.dim('🎛️ Enable or disable optional Sembix Studio services.'));
705
- console.log(chalk.dim(' Configure notifications and web application firewall settings.'));
829
+ return {
830
+ certificateArn: certificateArn || undefined,
831
+ cloudfrontDomain: cloudfrontDomain || undefined,
832
+ cloudfrontCertArn: cloudfrontCertArn || undefined,
833
+ bffAlbCertificateArn: bffAlbCertificateArn || undefined,
834
+ bffAlbInternal: bffAlbInternal || undefined,
835
+ bffAlbIngressCidrBlocks: bffAlbIngressCidrBlocks?.length ? bffAlbIngressCidrBlocks : undefined,
836
+ bffInternalAlbCertificateArn: bffInternalAlbCertificateArn || undefined,
837
+ privateCaCertSecretArn: privateCaCertSecretArn || undefined,
838
+ hostedZoneId: hostedZoneId || undefined,
839
+ };
840
+ }
841
+ // ============================================================
842
+ // STEP: FEATURE CONFIGURATION
843
+ // ============================================================
844
+ async function promptFeatures(ctx, stepNumber) {
845
+ // Express mode: auto-enable WAF
846
+ if (ctx.mode === 'express') {
847
+ ui.info('Using default Features: WAF enabled');
848
+ console.log();
849
+ return { enableBffWaf: true };
850
+ }
851
+ // Advanced mode: prompt
852
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Feature Configuration');
853
+ console.log(chalk.dim(' Enable or disable optional Sembix Studio services.'));
854
+ console.log(chalk.dim(' Configure web application firewall settings.'));
706
855
  console.log();
707
- // Sembix Studio Notifications
708
- explainField('Sembix Studio Notifications Service', 'The Notifications service handles alerts, messaging, and event notifications.\n\n' +
709
- chalk.green('✓ Recommended: ') + 'Enable this feature for user notifications and alerts.', 'Choose Yes (recommended)');
710
- const deploySembixStudioNotifications = await confirm({
711
- message: 'Deploy Sembix Studio Notifications service?',
712
- default: true,
713
- });
714
- // WAF
715
856
  explainField('Web Application Firewall (WAF)', 'AWS WAF protects your BFF API from common web exploits and attacks.\nIt provides an additional layer of security at the application level.\n\n' +
716
- chalk.green('Recommended: ') + 'Enable this for production environments.', 'Choose Yes (recommended for production)');
857
+ chalk.green(' Recommended: ') + 'Enable this for production environments.', 'Choose Yes (recommended for production)');
717
858
  const enableBffWaf = await confirm({
718
859
  message: 'Enable Web Application Firewall (WAF) for BFF?',
719
860
  default: true,
720
861
  });
721
- // ============================================================
722
- // STEP 8: FRONTEND CONFIGURATION
723
- // ============================================================
724
- ui.section('Step 8: Frontend Configuration');
725
- console.log(chalk.dim('⚛️ Configure integrations for the Sembix Studio frontend (React UI).'));
862
+ return { enableBffWaf };
863
+ }
864
+ // ============================================================
865
+ // STEP: FRONTEND CONFIGURATION
866
+ // ============================================================
867
+ async function promptFrontend(ctx, stepNumber) {
868
+ ui.stepHeader(stepNumber, getStepCount(ctx.mode), 'Frontend Configuration');
869
+ console.log(chalk.dim(' Configure integrations for the Sembix Studio frontend (React UI).'));
726
870
  console.log();
727
- // GitHub App Client ID
871
+ const { partialConfig } = ctx;
728
872
  const githubAppClientId = await promptIfMissing(partialConfig?.frontend?.githubAppClientId, async () => {
729
873
  explainField('GitHub App Client ID', 'The OAuth Client ID from your GitHub App.\nThis allows users to authenticate with GitHub and access repositories.\n\n' +
730
- chalk.green('💡 Tip: ') + 'Create a GitHub App in your organization settings, then copy the Client ID.', 'Iv1.0123456789abcdef');
874
+ chalk.green(' Tip: ') + 'Create a GitHub App in your organization settings, then copy the Client ID.', 'Iv1.0123456789abcdef');
731
875
  return input({
732
876
  message: 'GitHub App Client ID:',
733
877
  validate: (value) => {
@@ -737,7 +881,6 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
737
881
  },
738
882
  });
739
883
  }, 'GitHub App Client ID');
740
- // GitHub App Name
741
884
  const githubAppName = await promptIfMissing(partialConfig?.frontend?.githubAppName, async () => {
742
885
  explainField('GitHub App Name', 'The name of your GitHub App (as it appears in GitHub).\nThis is shown to users during the OAuth flow.', 'sembix-studio-app or acme-studio');
743
886
  return input({
@@ -749,10 +892,9 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
749
892
  },
750
893
  });
751
894
  }, 'GitHub App Name');
752
- // Jira Client ID
753
895
  const jiraClientId = await promptIfMissing(partialConfig?.frontend?.jiraClientId, async () => {
754
896
  explainField('Jira Client ID', 'The OAuth Client ID from your Atlassian/Jira app.\nThis enables Jira integration for issue tracking and project management.\n\n' +
755
- chalk.green('💡 Tip: ') + 'Create an OAuth app in your Atlassian Developer Console.', 'abc123def456ghi789');
897
+ chalk.green(' Tip: ') + 'Create an OAuth app in your Atlassian Developer Console.', 'abc123def456ghi789');
756
898
  return input({
757
899
  message: 'Jira Client ID:',
758
900
  validate: (value) => {
@@ -762,44 +904,103 @@ export async function promptEnvironmentSetup(githubClient, providedEnvironmentNa
762
904
  },
763
905
  });
764
906
  }, 'Jira Client ID');
907
+ return { githubAppClientId, githubAppName, jiraClientId };
908
+ }
909
+ // ============================================================
910
+ // ORCHESTRATOR
911
+ // ============================================================
912
+ export async function promptEnvironmentSetup(githubClient, providedEnvironmentName, providedRepo, partialConfig, isUpdate = false) {
913
+ // We need an initial mode to set up step numbering for the first step.
914
+ // Mode selection happens after repo, so use a preliminary context.
915
+ const preliminaryCtx = {
916
+ mode: 'express', // temporary — will be updated after mode selection
917
+ githubClient,
918
+ partialConfig,
919
+ isUpdate,
920
+ };
921
+ // Step 1: Repository Selection (always step 1)
922
+ const repository = await promptRepository(preliminaryCtx, 1, providedRepo);
923
+ // Mode Selection (between repo and basic config)
924
+ const mode = await promptModeSelection(partialConfig);
925
+ const ctx = { mode, githubClient, partialConfig, isUpdate };
926
+ // Step 2: Basic Configuration
927
+ const basicConfig = await promptBasicConfig(ctx, 2, repository.owner, repository.repo, providedEnvironmentName);
928
+ // Steps 3-5: Database, Networking, Security (Advanced only as visible steps)
929
+ let database;
930
+ let networking;
931
+ let security;
932
+ if (mode === 'advanced') {
933
+ database = await promptDatabase(ctx, 3);
934
+ networking = await promptNetworking(ctx, 4);
935
+ // Checkpoint: Infrastructure
936
+ ui.checkpoint('Infrastructure Basics', {
937
+ 'Environment': basicConfig.environmentName,
938
+ 'AWS Account': basicConfig.awsAccountId,
939
+ 'Region': basicConfig.awsRegion,
940
+ 'Database': database.name,
941
+ 'Networking': networking.useCustomNetworking ? `Custom VPC (${networking.customVpcId})` : 'New VPC (default)',
942
+ });
943
+ security = await promptSecurity(ctx, 5);
944
+ }
945
+ else {
946
+ // Express mode: auto-default DB, Networking, Security
947
+ database = await promptDatabase(ctx, 0); // step number unused in express
948
+ networking = await promptNetworking(ctx, 0);
949
+ security = await promptSecurity(ctx, 0);
950
+ }
951
+ // TLS step
952
+ const tlsStepNumber = mode === 'advanced' ? 6 : 3;
953
+ const tls = await promptTls(ctx, tlsStepNumber, basicConfig.awsRegion);
954
+ // Checkpoint: TLS & DNS
955
+ const tlsCheckpointData = {};
956
+ if (tls.cloudfrontDomain)
957
+ tlsCheckpointData['CloudFront Domain'] = tls.cloudfrontDomain;
958
+ if (tls.cloudfrontCertArn)
959
+ tlsCheckpointData['CloudFront Cert'] = tls.cloudfrontCertArn;
960
+ if (tls.bffAlbCertificateArn) {
961
+ tlsCheckpointData['Public ALB Cert'] = tls.bffAlbCertificateArn;
962
+ }
963
+ else if (tls.certificateArn) {
964
+ tlsCheckpointData['Public ALB Cert'] = '(using primary)';
965
+ }
966
+ if (tls.hostedZoneId)
967
+ tlsCheckpointData['Hosted Zone'] = tls.hostedZoneId;
968
+ if (Object.keys(tlsCheckpointData).length > 0) {
969
+ ui.checkpoint('TLS & DNS', tlsCheckpointData);
970
+ }
971
+ // Features step (Advanced only as visible step)
972
+ let features;
973
+ if (mode === 'advanced') {
974
+ features = await promptFeatures(ctx, 7);
975
+ }
976
+ else {
977
+ features = await promptFeatures(ctx, 0);
978
+ }
979
+ // Frontend step
980
+ const frontendStepNumber = mode === 'advanced' ? 8 : 4;
981
+ const frontend = await promptFrontend(ctx, frontendStepNumber);
765
982
  return {
766
- repository: { owner, repo },
767
- environmentName,
768
- awsAccountId,
769
- awsRegion,
770
- customerRoleArn,
771
- terraformStateBucket,
983
+ repository: { owner: repository.owner, repo: repository.repo },
984
+ environmentName: basicConfig.environmentName,
985
+ awsAccountId: basicConfig.awsAccountId,
986
+ awsRegion: basicConfig.awsRegion,
987
+ customerRoleArn: basicConfig.customerRoleArn,
988
+ terraformStateBucket: basicConfig.terraformStateBucket,
772
989
  database: {
773
- name: databaseName,
774
- user: databaseUser,
990
+ name: database.name,
991
+ user: database.user,
775
992
  },
776
993
  networking,
777
994
  security: {
778
- workflowRunsKeyAlias: workflowRunsKeyAlias || undefined,
779
- useCustomSecurityGroups,
780
- customSecurityGroups,
781
- useCustomIamPolicies,
782
- customIamRoles,
783
- },
784
- tls: {
785
- certificateArn: certificateArn || undefined,
786
- cloudfrontDomain,
787
- cloudfrontCertArn: cloudfrontCertArn || undefined,
788
- bffAlbCertificateArn: bffAlbCertificateArn || undefined,
789
- bffAlbInternal: bffAlbInternal || undefined,
790
- bffAlbIngressCidrBlocks: bffAlbIngressCidrBlocks?.length ? bffAlbIngressCidrBlocks : undefined,
791
- bffInternalAlbCertificateArn: bffInternalAlbCertificateArn || undefined,
792
- hostedZoneId,
793
- },
794
- features: {
795
- deploySembixStudioNotifications,
796
- enableBffWaf,
797
- },
798
- frontend: {
799
- githubAppClientId,
800
- githubAppName,
801
- jiraClientId,
995
+ workflowRunsKeyAlias: security.workflowRunsKeyAlias,
996
+ useCustomSecurityGroups: security.useCustomSecurityGroups,
997
+ customSecurityGroups: security.customSecurityGroups,
998
+ useCustomIamPolicies: security.useCustomIamPolicies,
999
+ customIamRoles: security.customIamRoles,
802
1000
  },
1001
+ tls,
1002
+ features,
1003
+ frontend,
803
1004
  };
804
1005
  }
805
1006
  //# sourceMappingURL=environment-setup.js.map