create-aws-project 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,8 +9,15 @@ import prompts from 'prompts';
9
9
  import pc from 'picocolors';
10
10
  import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { requireProjectContext } from '../utils/project-context.js';
12
- import { createOrganizationsClient, checkExistingOrganization, createOrganization, createAccount, waitForAccountCreation, } from '../aws/organizations.js';
13
- import { createCrossAccountIAMClient, createOrAdoptDeploymentUser, createCDKDeploymentPolicy, attachPolicyToUser, } from '../aws/iam.js';
12
+ import { loadSetupAwsEnvsConfig, deriveEnvironmentEmails } from '../config/non-interactive-aws.js';
13
+ import { createOrganizationsClient, checkExistingOrganization, createOrganization, createAccount, waitForAccountCreation, listOrganizationAccounts, } from '../aws/organizations.js';
14
+ import { createIAMClient, createCrossAccountIAMClient, createOrAdoptDeploymentUser, createCDKDeploymentPolicy, attachPolicyToUser, createAccessKey, } from '../aws/iam.js';
15
+ import { bootstrapAllEnvironments } from '../aws/cdk-bootstrap.js';
16
+ import { detectRootCredentials, createOrAdoptAdminUser, } from '../aws/root-credentials.js';
17
+ import { runInitializeGitHub } from './initialize-github.js';
18
+ import { IAMClient } from '@aws-sdk/client-iam';
19
+ import { OrganizationsClient } from '@aws-sdk/client-organizations';
20
+ import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
14
21
  /**
15
22
  * Environment names for account creation
16
23
  */
@@ -47,16 +54,21 @@ function validateUniqueEmail(value, existing) {
47
54
  return true;
48
55
  }
49
56
  /**
50
- * Collects unique email addresses for each environment account
57
+ * Collects unique email addresses for environments that need account creation
51
58
  * @param projectName Project name for display
52
- * @returns EnvironmentEmails object with dev, stage, prod emails
59
+ * @param environmentsToCreate Array of environment names that need accounts
60
+ * @returns Record of environment name to email address
53
61
  */
54
- async function collectEmails(projectName) {
62
+ async function collectEmails(projectName, environmentsToCreate) {
55
63
  console.log('');
56
64
  console.log(pc.bold('AWS Account Configuration'));
57
65
  console.log('');
58
66
  console.log(`Setting up environment accounts for ${pc.cyan(projectName)}`);
59
67
  console.log('');
68
+ if (environmentsToCreate.length < ENVIRONMENTS.length) {
69
+ console.log(pc.dim('Note: Only collecting emails for accounts that need creation.'));
70
+ console.log('');
71
+ }
60
72
  console.log('Each AWS environment account requires a unique root email address.');
61
73
  console.log(pc.dim('Tip: Use email aliases like yourname+dev@company.com'));
62
74
  console.log('');
@@ -64,56 +76,69 @@ async function collectEmails(projectName) {
64
76
  console.log(`\n${pc.red('x')} Setup cancelled`);
65
77
  process.exit(1);
66
78
  };
67
- // Collect emails sequentially to enable uniqueness validation
68
- const devResponse = await prompts({
69
- type: 'text',
70
- name: 'dev',
71
- message: 'Dev account root email:',
72
- validate: validateEmail,
73
- }, { onCancel });
74
- if (!devResponse.dev) {
75
- console.log(pc.red('Error:') + ' Dev email is required.');
76
- process.exit(1);
79
+ const emails = {};
80
+ const collectedEmails = [];
81
+ if (environmentsToCreate.includes('dev')) {
82
+ const response = await prompts({
83
+ type: 'text',
84
+ name: 'dev',
85
+ message: 'Dev account root email:',
86
+ validate: validateEmail,
87
+ }, { onCancel });
88
+ if (!response.dev) {
89
+ console.log(pc.red('Error:') + ' Dev email is required.');
90
+ process.exit(1);
91
+ }
92
+ emails.dev = response.dev;
93
+ collectedEmails.push(response.dev);
77
94
  }
78
- const stageResponse = await prompts({
79
- type: 'text',
80
- name: 'stage',
81
- message: 'Stage account root email:',
82
- validate: (v) => validateUniqueEmail(v, [devResponse.dev]),
83
- }, { onCancel });
84
- if (!stageResponse.stage) {
85
- console.log(pc.red('Error:') + ' Stage email is required.');
86
- process.exit(1);
95
+ if (environmentsToCreate.includes('stage')) {
96
+ const response = await prompts({
97
+ type: 'text',
98
+ name: 'stage',
99
+ message: 'Stage account root email:',
100
+ validate: (v) => validateUniqueEmail(v, collectedEmails),
101
+ }, { onCancel });
102
+ if (!response.stage) {
103
+ console.log(pc.red('Error:') + ' Stage email is required.');
104
+ process.exit(1);
105
+ }
106
+ emails.stage = response.stage;
107
+ collectedEmails.push(response.stage);
87
108
  }
88
- const prodResponse = await prompts({
89
- type: 'text',
90
- name: 'prod',
91
- message: 'Prod account root email:',
92
- validate: (v) => validateUniqueEmail(v, [devResponse.dev, stageResponse.stage]),
93
- }, { onCancel });
94
- if (!prodResponse.prod) {
95
- console.log(pc.red('Error:') + ' Prod email is required.');
96
- process.exit(1);
109
+ if (environmentsToCreate.includes('prod')) {
110
+ const response = await prompts({
111
+ type: 'text',
112
+ name: 'prod',
113
+ message: 'Prod account root email:',
114
+ validate: (v) => validateUniqueEmail(v, collectedEmails),
115
+ }, { onCancel });
116
+ if (!response.prod) {
117
+ console.log(pc.red('Error:') + ' Prod email is required.');
118
+ process.exit(1);
119
+ }
120
+ emails.prod = response.prod;
121
+ collectedEmails.push(response.prod);
97
122
  }
98
- return {
99
- dev: devResponse.dev,
100
- stage: stageResponse.stage,
101
- prod: prodResponse.prod,
102
- };
123
+ return emails;
103
124
  }
104
125
  /**
105
126
  * Updates the config file with account IDs and optionally deployment user names
106
127
  * @param configPath Path to the config file
107
128
  * @param accounts Record of environment to account ID
108
129
  * @param deploymentUsers Optional record of environment to deployment user name
130
+ * @param deploymentCredentials Optional record of environment to deployment credentials
109
131
  */
110
- function updateConfig(configPath, accounts, deploymentUsers) {
132
+ function updateConfig(configPath, accounts, deploymentUsers, deploymentCredentials) {
111
133
  const content = readFileSync(configPath, 'utf-8');
112
134
  const config = JSON.parse(content);
113
135
  config.accounts = { ...config.accounts, ...accounts };
114
136
  if (deploymentUsers) {
115
137
  config.deploymentUsers = { ...config.deploymentUsers, ...deploymentUsers };
116
138
  }
139
+ if (deploymentCredentials) {
140
+ config.deploymentCredentials = { ...config.deploymentCredentials, ...deploymentCredentials };
141
+ }
117
142
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
118
143
  }
119
144
  /**
@@ -166,20 +191,326 @@ function handleAwsError(error) {
166
191
  }
167
192
  process.exit(1);
168
193
  }
194
+ /**
195
+ * Runs setup-aws-envs in non-interactive mode using a JSON config file.
196
+ *
197
+ * Loads and validates the config, derives per-environment emails, then runs the
198
+ * full AWS setup flow (org, accounts, IAM users, access keys, CDK bootstrap)
199
+ * without any interactive prompts. After AWS setup completes, automatically
200
+ * invokes GitHub environment setup.
201
+ *
202
+ * Note: runInitializeGitHub may call process.exit(1) internally for auth failures
203
+ * (e.g. bad GitHub token). This bypasses the try/catch wrapper — it is a known
204
+ * limitation. The try/catch only catches thrown JavaScript errors (network, API).
205
+ *
206
+ * @param configPath Path to the JSON config file
207
+ */
208
+ async function runSetupAwsEnvsNonInteractive(configPath) {
209
+ // Load and validate config
210
+ const awsConfig = loadSetupAwsEnvsConfig(configPath);
211
+ // Get project context (validates we're in a project directory)
212
+ const context = await requireProjectContext();
213
+ const { config, configPath: projectConfigPath } = context;
214
+ // Derive per-environment emails from the single root email
215
+ const emails = deriveEnvironmentEmails(awsConfig.email, ENVIRONMENTS);
216
+ // Print derived emails for transparency before AWS operations begin
217
+ console.log('');
218
+ console.log('Derived environment emails:');
219
+ for (const [env, email] of Object.entries(emails)) {
220
+ console.log(` ${pc.cyan(env)}: ${email}`);
221
+ }
222
+ console.log('');
223
+ // Check if already configured (warn but don't abort - allows retry after partial failure)
224
+ const existingAccounts = config.accounts ?? {};
225
+ const existingUsers = config.deploymentUsers ?? {};
226
+ const existingCredentials = config.deploymentCredentials ?? {};
227
+ if (Object.keys(existingAccounts).length > 0) {
228
+ console.log('');
229
+ console.log(pc.yellow('Warning:') + ' AWS accounts already configured in this project:');
230
+ for (const [env, id] of Object.entries(existingAccounts)) {
231
+ const userInfo = existingUsers[env] ? ` (user: ${existingUsers[env]})` : '';
232
+ console.log(` ${env}: ${id}${userInfo}`);
233
+ }
234
+ console.log('');
235
+ console.log(pc.dim('Continuing will skip existing accounts and create any missing ones...'));
236
+ }
237
+ // Detect root credentials and create admin user if needed
238
+ let adminCredentials = null;
239
+ if (config.adminUser) {
240
+ console.log('');
241
+ console.log(pc.yellow('Note:') + ` Admin user ${pc.cyan(config.adminUser.userName)} already configured.`);
242
+ console.log(pc.dim('Using existing admin user. If you have switched to IAM credentials, root detection is skipped.'));
243
+ }
244
+ else {
245
+ try {
246
+ const identity = await detectRootCredentials(config.awsRegion);
247
+ if (identity.isRoot) {
248
+ console.log('');
249
+ console.log(pc.yellow('Root credentials detected.'));
250
+ console.log('Creating admin IAM user for subsequent operations...');
251
+ console.log('');
252
+ const iamClient = createIAMClient(config.awsRegion);
253
+ const adminResult = await createOrAdoptAdminUser(iamClient, config.projectName);
254
+ adminCredentials = {
255
+ accessKeyId: adminResult.accessKeyId,
256
+ secretAccessKey: adminResult.secretAccessKey,
257
+ };
258
+ const configContent = JSON.parse(readFileSync(projectConfigPath, 'utf-8'));
259
+ configContent.adminUser = {
260
+ userName: adminResult.userName,
261
+ accessKeyId: adminResult.accessKeyId,
262
+ };
263
+ writeFileSync(projectConfigPath, JSON.stringify(configContent, null, 2) + '\n', 'utf-8');
264
+ if (adminResult.adopted) {
265
+ console.log(pc.green(`Adopted existing admin user: ${adminResult.userName}`));
266
+ }
267
+ else {
268
+ console.log(pc.green(`Created admin user: ${adminResult.userName}`));
269
+ }
270
+ console.log(pc.dim('Admin credentials will be used for all subsequent AWS operations in this session.'));
271
+ console.log('');
272
+ }
273
+ }
274
+ catch (error) {
275
+ const spinner = ora('').start();
276
+ spinner.fail('Failed to detect AWS credentials');
277
+ handleAwsError(error);
278
+ }
279
+ }
280
+ // Execute AWS operations with progress spinner
281
+ const spinner = ora('Starting AWS Organizations setup...').start();
282
+ try {
283
+ // Organizations API requires us-east-1
284
+ let client;
285
+ if (adminCredentials) {
286
+ client = new OrganizationsClient({
287
+ region: 'us-east-1',
288
+ credentials: {
289
+ accessKeyId: adminCredentials.accessKeyId,
290
+ secretAccessKey: adminCredentials.secretAccessKey,
291
+ },
292
+ });
293
+ }
294
+ else {
295
+ client = createOrganizationsClient('us-east-1');
296
+ }
297
+ // Check/create organization
298
+ spinner.text = 'Checking for existing AWS Organization...';
299
+ let orgId = await checkExistingOrganization(client);
300
+ if (!orgId) {
301
+ spinner.text = 'Creating AWS Organization...';
302
+ orgId = await createOrganization(client);
303
+ spinner.succeed(`Created AWS Organization: ${orgId}`);
304
+ }
305
+ else {
306
+ spinner.succeed(`Using existing AWS Organization: ${orgId}`);
307
+ }
308
+ // Discover existing accounts from AWS (source of truth)
309
+ spinner.start('Checking for existing AWS accounts...');
310
+ const allOrgAccounts = await listOrganizationAccounts(client);
311
+ const discoveredAccounts = new Map();
312
+ for (const account of allOrgAccounts) {
313
+ for (const env of ENVIRONMENTS) {
314
+ const expectedName = `${config.projectName}-${env}`;
315
+ if (account.Name === expectedName && account.Id) {
316
+ discoveredAccounts.set(env, account.Id);
317
+ }
318
+ }
319
+ }
320
+ // Determine which environments still need account creation
321
+ const environmentsNeedingCreation = ENVIRONMENTS.filter(env => !discoveredAccounts.has(env));
322
+ // Report findings
323
+ for (const [env, accountId] of discoveredAccounts.entries()) {
324
+ spinner.info(`Found existing ${env} account: ${accountId}`);
325
+ }
326
+ // Warn if config has accounts not found in AWS
327
+ for (const [env, accountId] of Object.entries(existingAccounts)) {
328
+ if (!discoveredAccounts.has(env)) {
329
+ console.log(pc.yellow('Warning:') + ` Account ${env} (${accountId}) in config but not found in AWS Organization`);
330
+ }
331
+ }
332
+ // Non-interactive: emails are already derived — no collectEmails() call
333
+ if (environmentsNeedingCreation.length > 0) {
334
+ spinner.start('Continuing AWS setup with derived emails...');
335
+ }
336
+ else {
337
+ spinner.stop();
338
+ console.log('');
339
+ console.log(pc.green('All environment accounts already exist in AWS.'));
340
+ console.log(pc.dim('Skipping email collection, proceeding to deployment user setup...'));
341
+ console.log('');
342
+ spinner.start('Continuing AWS setup...');
343
+ }
344
+ // Create accounts sequentially (AWS rate limits require sequential)
345
+ const accounts = { ...existingAccounts };
346
+ for (const [env, accountId] of discoveredAccounts.entries()) {
347
+ accounts[env] = accountId;
348
+ }
349
+ if (discoveredAccounts.size > 0) {
350
+ updateConfig(projectConfigPath, accounts);
351
+ }
352
+ for (const env of ENVIRONMENTS) {
353
+ if (accounts[env]) {
354
+ spinner.succeed(`Using existing ${env} account: ${accounts[env]}`);
355
+ continue;
356
+ }
357
+ spinner.start(`Creating ${env} account (this may take several minutes)...`);
358
+ const accountName = `${config.projectName}-${env}`;
359
+ const { requestId } = await createAccount(client, emails[env], accountName);
360
+ spinner.text = `Waiting for ${env} account creation...`;
361
+ const result = await waitForAccountCreation(client, requestId);
362
+ accounts[env] = result.accountId;
363
+ updateConfig(projectConfigPath, accounts);
364
+ spinner.succeed(`Created ${env} account: ${result.accountId}`);
365
+ }
366
+ // Create IAM deployment users in each account
367
+ const deploymentUsers = { ...existingUsers };
368
+ for (const env of ENVIRONMENTS) {
369
+ if (existingUsers[env]) {
370
+ spinner.succeed(`Using existing deployment user: ${existingUsers[env]}`);
371
+ continue;
372
+ }
373
+ const accountId = accounts[env];
374
+ const userName = `${config.projectName}-${env}-deploy`;
375
+ const policyName = `${config.projectName}-${env}-cdk-deploy`;
376
+ spinner.start(`Creating deployment user in ${env} account...`);
377
+ let iamClient;
378
+ if (adminCredentials) {
379
+ const roleArn = `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`;
380
+ iamClient = new IAMClient({
381
+ region: config.awsRegion,
382
+ credentials: fromTemporaryCredentials({
383
+ masterCredentials: {
384
+ accessKeyId: adminCredentials.accessKeyId,
385
+ secretAccessKey: adminCredentials.secretAccessKey,
386
+ },
387
+ params: {
388
+ RoleArn: roleArn,
389
+ RoleSessionName: `create-aws-project-${Date.now()}`,
390
+ DurationSeconds: 900,
391
+ },
392
+ }),
393
+ });
394
+ }
395
+ else {
396
+ iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
397
+ }
398
+ await createOrAdoptDeploymentUser(iamClient, userName);
399
+ spinner.text = `Creating deployment policy for ${env}...`;
400
+ const policyArn = await createCDKDeploymentPolicy(iamClient, policyName, accountId);
401
+ await attachPolicyToUser(iamClient, userName, policyArn);
402
+ deploymentUsers[env] = userName;
403
+ updateConfig(projectConfigPath, accounts, deploymentUsers);
404
+ spinner.succeed(`Created deployment user: ${userName}`);
405
+ }
406
+ // Create access keys for deployment users
407
+ const deploymentCredentials = {};
408
+ for (const env of ENVIRONMENTS) {
409
+ if (existingCredentials[env]) {
410
+ spinner.succeed(`Using existing credentials for ${env}: ${existingCredentials[env].accessKeyId}`);
411
+ deploymentCredentials[env] = existingCredentials[env];
412
+ continue;
413
+ }
414
+ const userName = deploymentUsers[env];
415
+ spinner.start(`Creating access key for ${env} deployment user...`);
416
+ const accountId = accounts[env];
417
+ let iamClient;
418
+ if (adminCredentials) {
419
+ const roleArn = `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`;
420
+ iamClient = new IAMClient({
421
+ region: config.awsRegion,
422
+ credentials: fromTemporaryCredentials({
423
+ masterCredentials: {
424
+ accessKeyId: adminCredentials.accessKeyId,
425
+ secretAccessKey: adminCredentials.secretAccessKey,
426
+ },
427
+ params: {
428
+ RoleArn: roleArn,
429
+ RoleSessionName: `create-aws-project-${Date.now()}`,
430
+ DurationSeconds: 900,
431
+ },
432
+ }),
433
+ });
434
+ }
435
+ else {
436
+ iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
437
+ }
438
+ const credentials = await createAccessKey(iamClient, userName);
439
+ deploymentCredentials[env] = {
440
+ userName,
441
+ accessKeyId: credentials.accessKeyId,
442
+ secretAccessKey: credentials.secretAccessKey,
443
+ };
444
+ updateConfig(projectConfigPath, accounts, deploymentUsers, deploymentCredentials);
445
+ spinner.succeed(`Created access key for ${userName}`);
446
+ }
447
+ // Bootstrap CDK in each environment account
448
+ await bootstrapAllEnvironments({
449
+ accounts,
450
+ region: config.awsRegion,
451
+ adminCredentials,
452
+ spinner,
453
+ });
454
+ // Final success with summary table
455
+ console.log('');
456
+ console.log(pc.green('AWS environment setup complete!'));
457
+ console.log('');
458
+ console.log('Summary:');
459
+ console.log(` ${'Environment'.padEnd(14)}${'Account ID'.padEnd(16)}${'Deployment User'.padEnd(30)}Access Key`);
460
+ for (const env of ENVIRONMENTS) {
461
+ const keyId = deploymentCredentials[env]?.accessKeyId ?? 'N/A';
462
+ console.log(` ${env.padEnd(14)}${accounts[env].padEnd(16)}${deploymentUsers[env].padEnd(30)}${keyId}`);
463
+ }
464
+ console.log('');
465
+ console.log(pc.dim('CDK bootstrapped in all environment accounts.'));
466
+ console.log('');
467
+ console.log('AWS setup complete. All environments bootstrapped and ready for CDK deployments.');
468
+ console.log('');
469
+ // Auto-run GitHub setup (non-interactive path — no prompt)
470
+ console.log('Setting up GitHub environments...');
471
+ try {
472
+ await runInitializeGitHub(['--all']);
473
+ }
474
+ catch (error) {
475
+ const msg = error instanceof Error ? error.message : String(error);
476
+ console.warn(pc.yellow('Warning:') + ` GitHub setup failed: ${msg}`);
477
+ console.warn('AWS setup completed successfully. Run initialize-github manually:');
478
+ console.warn(` ${pc.cyan('npx create-aws-project initialize-github --all')}`);
479
+ }
480
+ }
481
+ catch (error) {
482
+ spinner.fail('AWS setup failed');
483
+ handleAwsError(error);
484
+ }
485
+ process.exit(0);
486
+ }
169
487
  /**
170
488
  * Runs the setup-aws-envs command
171
489
  *
172
490
  * Creates AWS Organizations and environment accounts (dev, stage, prod)
173
491
  *
174
- * @param _args Command arguments (unused)
492
+ * @param args Command arguments
175
493
  */
176
- export async function runSetupAwsEnvs(_args) {
494
+ export async function runSetupAwsEnvs(args) {
495
+ // --config flag: route to non-interactive mode
496
+ const configFlagIndex = args.findIndex(arg => arg === '--config');
497
+ if (configFlagIndex !== -1) {
498
+ const configPath = args[configFlagIndex + 1];
499
+ if (!configPath || configPath.startsWith('--')) {
500
+ console.error(pc.red('Error:') + ' --config requires a path argument');
501
+ console.error(' Example: npx create-aws-project setup-aws-envs --config aws.json');
502
+ process.exit(1);
503
+ }
504
+ await runSetupAwsEnvsNonInteractive(configPath);
505
+ return;
506
+ }
177
507
  // 1. Validate we're in a project directory
178
508
  const context = await requireProjectContext();
179
509
  const { config, configPath } = context;
180
510
  // 2. Check if already configured (warn but don't abort - allows retry after partial failure)
181
511
  const existingAccounts = config.accounts ?? {};
182
512
  const existingUsers = config.deploymentUsers ?? {};
513
+ const existingCredentials = config.deploymentCredentials ?? {};
183
514
  if (Object.keys(existingAccounts).length > 0) {
184
515
  console.log('');
185
516
  console.log(pc.yellow('Warning:') + ' AWS accounts already configured in this project:');
@@ -190,13 +521,74 @@ export async function runSetupAwsEnvs(_args) {
190
521
  console.log('');
191
522
  console.log(pc.dim('Continuing will skip existing accounts and create any missing ones...'));
192
523
  }
193
- // 3. Collect emails (all input before any AWS calls - per research pitfall #6)
194
- const emails = await collectEmails(config.projectName);
524
+ // 3. Detect root credentials and create admin user if needed
525
+ let adminCredentials = null;
526
+ if (config.adminUser) {
527
+ // Admin user already created in a previous run - skip root detection
528
+ console.log('');
529
+ console.log(pc.yellow('Note:') + ` Admin user ${pc.cyan(config.adminUser.userName)} already configured.`);
530
+ console.log(pc.dim('Using existing admin user. If you have switched to IAM credentials, root detection is skipped.'));
531
+ }
532
+ else {
533
+ // Check if current credentials are root
534
+ try {
535
+ const identity = await detectRootCredentials(config.awsRegion);
536
+ if (identity.isRoot) {
537
+ console.log('');
538
+ console.log(pc.yellow('Root credentials detected.'));
539
+ console.log('Creating admin IAM user for subsequent operations...');
540
+ console.log('');
541
+ // Create IAM client with current (root) credentials for management account
542
+ const iamClient = createIAMClient(config.awsRegion);
543
+ const adminResult = await createOrAdoptAdminUser(iamClient, config.projectName);
544
+ // Store admin credentials for use in this session
545
+ adminCredentials = {
546
+ accessKeyId: adminResult.accessKeyId,
547
+ secretAccessKey: adminResult.secretAccessKey,
548
+ };
549
+ // Persist admin user info to config (without secret key)
550
+ const configContent = JSON.parse(readFileSync(configPath, 'utf-8'));
551
+ configContent.adminUser = {
552
+ userName: adminResult.userName,
553
+ accessKeyId: adminResult.accessKeyId,
554
+ };
555
+ writeFileSync(configPath, JSON.stringify(configContent, null, 2) + '\n', 'utf-8');
556
+ if (adminResult.adopted) {
557
+ console.log(pc.green(`Adopted existing admin user: ${adminResult.userName}`));
558
+ }
559
+ else {
560
+ console.log(pc.green(`Created admin user: ${adminResult.userName}`));
561
+ }
562
+ console.log(pc.dim('Admin credentials will be used for all subsequent AWS operations in this session.'));
563
+ console.log('');
564
+ }
565
+ }
566
+ catch (error) {
567
+ // If STS call fails, let it propagate as an AWS error
568
+ // This likely means credentials are not configured at all
569
+ const spinner = ora('').start();
570
+ spinner.fail('Failed to detect AWS credentials');
571
+ handleAwsError(error);
572
+ }
573
+ }
195
574
  // 4. Execute AWS operations with progress spinner
196
575
  const spinner = ora('Starting AWS Organizations setup...').start();
197
576
  try {
198
577
  // Organizations API requires us-east-1 (region-locked per research pitfall #1)
199
- const client = createOrganizationsClient('us-east-1');
578
+ // When admin credentials available, create OrganizationsClient with explicit credentials
579
+ let client;
580
+ if (adminCredentials) {
581
+ client = new OrganizationsClient({
582
+ region: 'us-east-1',
583
+ credentials: {
584
+ accessKeyId: adminCredentials.accessKeyId,
585
+ secretAccessKey: adminCredentials.secretAccessKey,
586
+ },
587
+ });
588
+ }
589
+ else {
590
+ client = createOrganizationsClient('us-east-1');
591
+ }
200
592
  // Check/create organization
201
593
  spinner.text = 'Checking for existing AWS Organization...';
202
594
  let orgId = await checkExistingOrganization(client);
@@ -208,12 +600,59 @@ export async function runSetupAwsEnvs(_args) {
208
600
  else {
209
601
  spinner.succeed(`Using existing AWS Organization: ${orgId}`);
210
602
  }
603
+ // Discover existing accounts from AWS (source of truth)
604
+ spinner.start('Checking for existing AWS accounts...');
605
+ const allOrgAccounts = await listOrganizationAccounts(client);
606
+ // Map accounts by environment using name pattern matching
607
+ const discoveredAccounts = new Map();
608
+ for (const account of allOrgAccounts) {
609
+ for (const env of ENVIRONMENTS) {
610
+ const expectedName = `${config.projectName}-${env}`;
611
+ if (account.Name === expectedName && account.Id) {
612
+ discoveredAccounts.set(env, account.Id);
613
+ }
614
+ }
615
+ }
616
+ // Determine which environments still need account creation
617
+ const environmentsNeedingCreation = ENVIRONMENTS.filter(env => !discoveredAccounts.has(env));
618
+ // Report findings
619
+ for (const [env, accountId] of discoveredAccounts.entries()) {
620
+ spinner.info(`Found existing ${env} account: ${accountId}`);
621
+ }
622
+ // Warn if config has accounts not found in AWS
623
+ for (const [env, accountId] of Object.entries(existingAccounts)) {
624
+ if (!discoveredAccounts.has(env)) {
625
+ console.log(pc.yellow('Warning:') + ` Account ${env} (${accountId}) in config but not found in AWS Organization`);
626
+ }
627
+ }
628
+ spinner.stop();
629
+ // Collect emails only for environments that need account creation
630
+ let emails = {};
631
+ if (environmentsNeedingCreation.length > 0) {
632
+ // Must be outside spinner (prompts conflict with ora)
633
+ emails = await collectEmails(config.projectName, environmentsNeedingCreation);
634
+ spinner.start('Continuing AWS setup...');
635
+ }
636
+ else {
637
+ console.log('');
638
+ console.log(pc.green('All environment accounts already exist in AWS.'));
639
+ console.log(pc.dim('Skipping email collection, proceeding to deployment user setup...'));
640
+ console.log('');
641
+ }
211
642
  // Create accounts sequentially (per research - AWS rate limits require sequential)
643
+ // Start with accounts discovered from AWS (source of truth)
212
644
  const accounts = { ...existingAccounts };
645
+ for (const [env, accountId] of discoveredAccounts.entries()) {
646
+ accounts[env] = accountId;
647
+ }
648
+ // Update config with discovered accounts (sync config with AWS state)
649
+ if (discoveredAccounts.size > 0) {
650
+ updateConfig(configPath, accounts);
651
+ }
213
652
  for (const env of ENVIRONMENTS) {
214
- // Skip if account already exists
215
- if (existingAccounts[env]) {
216
- spinner.succeed(`Using existing ${env} account: ${existingAccounts[env]}`);
653
+ // Skip if account already exists (in config OR discovered from AWS)
654
+ if (accounts[env]) {
655
+ spinner.succeed(`Using existing ${env} account: ${accounts[env]}`);
217
656
  continue;
218
657
  }
219
658
  spinner.start(`Creating ${env} account (this may take several minutes)...`);
@@ -238,7 +677,29 @@ export async function runSetupAwsEnvs(_args) {
238
677
  const userName = `${config.projectName}-${env}-deploy`;
239
678
  const policyName = `${config.projectName}-${env}-cdk-deploy`;
240
679
  spinner.start(`Creating deployment user in ${env} account...`);
241
- const iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
680
+ // When admin credentials available, create cross-account client with explicit credentials
681
+ let iamClient;
682
+ if (adminCredentials) {
683
+ // Use admin credentials as the source for role assumption
684
+ const roleArn = `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`;
685
+ iamClient = new IAMClient({
686
+ region: config.awsRegion,
687
+ credentials: fromTemporaryCredentials({
688
+ masterCredentials: {
689
+ accessKeyId: adminCredentials.accessKeyId,
690
+ secretAccessKey: adminCredentials.secretAccessKey,
691
+ },
692
+ params: {
693
+ RoleArn: roleArn,
694
+ RoleSessionName: `create-aws-project-${Date.now()}`,
695
+ DurationSeconds: 900,
696
+ },
697
+ }),
698
+ });
699
+ }
700
+ else {
701
+ iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
702
+ }
242
703
  await createOrAdoptDeploymentUser(iamClient, userName);
243
704
  spinner.text = `Creating deployment policy for ${env}...`;
244
705
  const policyArn = await createCDKDeploymentPolicy(iamClient, policyName, accountId);
@@ -248,18 +709,95 @@ export async function runSetupAwsEnvs(_args) {
248
709
  updateConfig(configPath, accounts, deploymentUsers);
249
710
  spinner.succeed(`Created deployment user: ${userName}`);
250
711
  }
712
+ // Create access keys for deployment users
713
+ const deploymentCredentials = {};
714
+ for (const env of ENVIRONMENTS) {
715
+ // Skip if credentials already exist in config
716
+ if (existingCredentials[env]) {
717
+ spinner.succeed(`Using existing credentials for ${env}: ${existingCredentials[env].accessKeyId}`);
718
+ deploymentCredentials[env] = existingCredentials[env];
719
+ continue;
720
+ }
721
+ const userName = deploymentUsers[env];
722
+ spinner.start(`Creating access key for ${env} deployment user...`);
723
+ // Use the same cross-account IAM client pattern as deployment user creation
724
+ const accountId = accounts[env];
725
+ let iamClient;
726
+ if (adminCredentials) {
727
+ const roleArn = `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`;
728
+ iamClient = new IAMClient({
729
+ region: config.awsRegion,
730
+ credentials: fromTemporaryCredentials({
731
+ masterCredentials: {
732
+ accessKeyId: adminCredentials.accessKeyId,
733
+ secretAccessKey: adminCredentials.secretAccessKey,
734
+ },
735
+ params: {
736
+ RoleArn: roleArn,
737
+ RoleSessionName: `create-aws-project-${Date.now()}`,
738
+ DurationSeconds: 900,
739
+ },
740
+ }),
741
+ });
742
+ }
743
+ else {
744
+ iamClient = createCrossAccountIAMClient(config.awsRegion, accountId);
745
+ }
746
+ const credentials = await createAccessKey(iamClient, userName);
747
+ deploymentCredentials[env] = {
748
+ userName,
749
+ accessKeyId: credentials.accessKeyId,
750
+ secretAccessKey: credentials.secretAccessKey,
751
+ };
752
+ // Save after EACH successful key creation (partial failure resilience)
753
+ updateConfig(configPath, accounts, deploymentUsers, deploymentCredentials);
754
+ spinner.succeed(`Created access key for ${userName}`);
755
+ }
756
+ // Bootstrap CDK in each environment account
757
+ await bootstrapAllEnvironments({
758
+ accounts,
759
+ region: config.awsRegion,
760
+ adminCredentials,
761
+ spinner,
762
+ });
251
763
  // Final success with summary table
252
764
  console.log('');
253
765
  console.log(pc.green('AWS environment setup complete!'));
254
766
  console.log('');
255
767
  console.log('Summary:');
256
- console.log(` ${'Environment'.padEnd(14)}${'Account ID'.padEnd(16)}Deployment User`);
768
+ console.log(` ${'Environment'.padEnd(14)}${'Account ID'.padEnd(16)}${'Deployment User'.padEnd(30)}Access Key`);
257
769
  for (const env of ENVIRONMENTS) {
258
- console.log(` ${env.padEnd(14)}${accounts[env].padEnd(16)}${deploymentUsers[env]}`);
770
+ const keyId = deploymentCredentials[env]?.accessKeyId ?? 'N/A';
771
+ console.log(` ${env.padEnd(14)}${accounts[env].padEnd(16)}${deploymentUsers[env].padEnd(30)}${keyId}`);
259
772
  }
260
773
  console.log('');
261
- console.log('Deployment users created. Next: initialize-github to create access keys and push to GitHub.');
262
- console.log(` ${pc.cyan('npx create-aws-project initialize-github dev')}`);
774
+ console.log(pc.dim('CDK bootstrapped in all environment accounts.'));
775
+ console.log('');
776
+ console.log('AWS setup complete. All environments bootstrapped and ready for CDK deployments.');
777
+ console.log('');
778
+ // Offer continuation to GitHub setup
779
+ const continueResponse = await prompts({
780
+ type: 'confirm',
781
+ name: 'continueToGitHub',
782
+ message: 'Continue to GitHub Environment setup?',
783
+ initial: true,
784
+ }, {
785
+ onCancel: () => {
786
+ // User pressed Ctrl+C — show next step hint and exit gracefully
787
+ console.log('');
788
+ console.log('Next: Push credentials to GitHub:');
789
+ console.log(` ${pc.cyan('npx create-aws-project initialize-github --all')}`);
790
+ },
791
+ });
792
+ if (continueResponse.continueToGitHub) {
793
+ console.log('');
794
+ await runInitializeGitHub(['--all']);
795
+ }
796
+ else {
797
+ console.log('');
798
+ console.log('Next: Push credentials to GitHub:');
799
+ console.log(` ${pc.cyan('npx create-aws-project initialize-github --all')}`);
800
+ }
263
801
  }
264
802
  catch (error) {
265
803
  spinner.fail('AWS setup failed');