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.
- package/dist/__tests__/aws/cdk-bootstrap.spec.d.ts +1 -0
- package/dist/__tests__/aws/cdk-bootstrap.spec.js +266 -0
- package/dist/__tests__/aws/root-credentials.spec.d.ts +1 -0
- package/dist/__tests__/aws/root-credentials.spec.js +230 -0
- package/dist/__tests__/config/non-interactive-aws.spec.d.ts +1 -0
- package/dist/__tests__/config/non-interactive-aws.spec.js +100 -0
- package/dist/__tests__/config/non-interactive.spec.d.ts +1 -0
- package/dist/__tests__/config/non-interactive.spec.js +152 -0
- package/dist/aws/cdk-bootstrap.d.ts +77 -0
- package/dist/aws/cdk-bootstrap.js +139 -0
- package/dist/aws/iam.js +17 -0
- package/dist/aws/organizations.d.ts +7 -1
- package/dist/aws/organizations.js +19 -1
- package/dist/aws/root-credentials.d.ts +68 -0
- package/dist/aws/root-credentials.js +157 -0
- package/dist/cli.js +58 -23
- package/dist/commands/initialize-github.js +162 -83
- package/dist/commands/setup-aws-envs.d.ts +2 -2
- package/dist/commands/setup-aws-envs.js +590 -52
- package/dist/config/non-interactive-aws.d.ts +27 -0
- package/dist/config/non-interactive-aws.js +80 -0
- package/dist/config/non-interactive.d.ts +48 -0
- package/dist/config/non-interactive.js +92 -0
- package/dist/git/setup.d.ts +3 -3
- package/dist/git/setup.js +28 -24
- package/dist/utils/project-context.d.ts +14 -0
- package/package.json +4 -3
- package/templates/apps/web/src/App.tsx +4 -2
- package/templates/apps/web/src/__tests__/App.spec.tsx +27 -9
- package/templates/apps/web/vite.config.ts +3 -0
|
@@ -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 {
|
|
13
|
-
import {
|
|
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
|
|
57
|
+
* Collects unique email addresses for environments that need account creation
|
|
51
58
|
* @param projectName Project name for display
|
|
52
|
-
* @
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
492
|
+
* @param args Command arguments
|
|
175
493
|
*/
|
|
176
|
-
export async function runSetupAwsEnvs(
|
|
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.
|
|
194
|
-
|
|
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
|
-
|
|
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 (
|
|
216
|
-
spinner.succeed(`Using existing ${env} account: ${
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
262
|
-
console.log(
|
|
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');
|