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.
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Mock picocolors to avoid console output issues in tests
6
+ jest.unstable_mockModule('picocolors', () => ({
7
+ __esModule: true,
8
+ default: {
9
+ red: (s) => s,
10
+ green: (s) => s,
11
+ blue: (s) => s,
12
+ yellow: (s) => s,
13
+ cyan: (s) => s,
14
+ magenta: (s) => s,
15
+ bold: (s) => s,
16
+ dim: (s) => s,
17
+ },
18
+ }));
19
+ // Dynamic import after mocking
20
+ const { loadNonInteractiveConfig } = await import('../../config/non-interactive.js');
21
+ // Helper to write a temp config file and return its path
22
+ function writeTempConfig(content) {
23
+ const tmpFile = join(tmpdir(), `non-interactive-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
24
+ writeFileSync(tmpFile, typeof content === 'string' ? content : JSON.stringify(content));
25
+ return tmpFile;
26
+ }
27
+ describe('loadNonInteractiveConfig', () => {
28
+ beforeEach(() => {
29
+ // Mock process.exit to throw so tests can catch exit calls
30
+ jest.spyOn(process, 'exit').mockImplementation((() => {
31
+ throw new Error('process.exit called');
32
+ }));
33
+ // Suppress console.error output during tests
34
+ jest.spyOn(console, 'error').mockImplementation(() => { });
35
+ });
36
+ describe('schema defaults (NI-04)', () => {
37
+ it('applies all defaults when only name is provided', () => {
38
+ const tmpFile = writeTempConfig({ name: 'my-app' });
39
+ const config = loadNonInteractiveConfig(tmpFile);
40
+ expect(config.projectName).toBe('my-app');
41
+ expect(config.platforms).toEqual(['web', 'api']);
42
+ expect(config.auth.provider).toBe('none');
43
+ expect(config.auth.features).toEqual([]);
44
+ expect(config.features).toEqual(['github-actions', 'vscode-config']);
45
+ expect(config.awsRegion).toBe('us-east-1');
46
+ expect(config.brandColor).toBe('blue');
47
+ });
48
+ it('uses specified values when all fields provided', () => {
49
+ const tmpFile = writeTempConfig({
50
+ name: 'full-app',
51
+ platforms: ['web', 'mobile', 'api'],
52
+ auth: 'cognito',
53
+ authFeatures: ['social-login', 'mfa'],
54
+ features: ['github-actions'],
55
+ region: 'eu-west-1',
56
+ brandColor: 'purple',
57
+ });
58
+ const config = loadNonInteractiveConfig(tmpFile);
59
+ expect(config.projectName).toBe('full-app');
60
+ expect(config.platforms).toEqual(['web', 'mobile', 'api']);
61
+ expect(config.auth.provider).toBe('cognito');
62
+ expect(config.auth.features).toEqual(['social-login', 'mfa']);
63
+ expect(config.features).toEqual(['github-actions']);
64
+ expect(config.awsRegion).toBe('eu-west-1');
65
+ expect(config.brandColor).toBe('purple');
66
+ });
67
+ });
68
+ describe('schema validation (NI-05)', () => {
69
+ it('exits with error when name is missing', () => {
70
+ const tmpFile = writeTempConfig({});
71
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
72
+ expect(process.exit).toHaveBeenCalledWith(1);
73
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('name'));
74
+ });
75
+ it('exits with error when name is empty string', () => {
76
+ const tmpFile = writeTempConfig({ name: '' });
77
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
78
+ expect(process.exit).toHaveBeenCalledWith(1);
79
+ });
80
+ it('exits with error for invalid platform value', () => {
81
+ const tmpFile = writeTempConfig({ name: 'x', platforms: ['invalid'] });
82
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
83
+ expect(process.exit).toHaveBeenCalledWith(1);
84
+ });
85
+ it('exits with error for invalid auth provider', () => {
86
+ const tmpFile = writeTempConfig({ name: 'x', auth: 'firebase' });
87
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
88
+ expect(process.exit).toHaveBeenCalledWith(1);
89
+ });
90
+ it('exits with error for invalid region', () => {
91
+ const tmpFile = writeTempConfig({ name: 'x', region: 'mars-1' });
92
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
93
+ expect(process.exit).toHaveBeenCalledWith(1);
94
+ });
95
+ it('exits with error for invalid brand color', () => {
96
+ const tmpFile = writeTempConfig({ name: 'x', brandColor: 'red' });
97
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
98
+ expect(process.exit).toHaveBeenCalledWith(1);
99
+ });
100
+ it('reports multiple validation failures at once', () => {
101
+ const tmpFile = writeTempConfig({
102
+ // name missing AND multiple invalid fields
103
+ platforms: ['bad'],
104
+ auth: 'bad',
105
+ region: 'bad',
106
+ });
107
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
108
+ expect(process.exit).toHaveBeenCalledWith(1);
109
+ // At least 3 distinct error calls for the 3+ failing fields
110
+ expect(console.error.mock.calls.length).toBeGreaterThanOrEqual(3);
111
+ });
112
+ });
113
+ describe('auth normalization', () => {
114
+ it('silently drops authFeatures when auth is none', () => {
115
+ const tmpFile = writeTempConfig({
116
+ name: 'x',
117
+ auth: 'none',
118
+ authFeatures: ['social-login'],
119
+ });
120
+ const config = loadNonInteractiveConfig(tmpFile);
121
+ expect(config.auth.features).toEqual([]);
122
+ });
123
+ it('preserves authFeatures when auth is cognito', () => {
124
+ const tmpFile = writeTempConfig({
125
+ name: 'x',
126
+ auth: 'cognito',
127
+ authFeatures: ['mfa'],
128
+ });
129
+ const config = loadNonInteractiveConfig(tmpFile);
130
+ expect(config.auth.features).toEqual(['mfa']);
131
+ });
132
+ });
133
+ describe('file errors', () => {
134
+ it('exits with error for non-existent file', () => {
135
+ expect(() => loadNonInteractiveConfig('/nonexistent/path.json')).toThrow('process.exit called');
136
+ expect(process.exit).toHaveBeenCalledWith(1);
137
+ });
138
+ it('exits with error for invalid JSON', () => {
139
+ const tmpFile = writeTempConfig('not json {{{');
140
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
141
+ expect(process.exit).toHaveBeenCalledWith(1);
142
+ });
143
+ });
144
+ describe('project name validation', () => {
145
+ it('exits with error for invalid npm package name', () => {
146
+ // npm package names cannot have uppercase letters
147
+ const tmpFile = writeTempConfig({ name: 'UPPERCASE' });
148
+ expect(() => loadNonInteractiveConfig(tmpFile)).toThrow('process.exit called');
149
+ expect(process.exit).toHaveBeenCalledWith(1);
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,77 @@
1
+ import type { Ora } from 'ora';
2
+ /**
3
+ * Credentials for AWS API operations
4
+ */
5
+ export interface AWSCredentials {
6
+ accessKeyId: string;
7
+ secretAccessKey: string;
8
+ sessionToken?: string;
9
+ }
10
+ /**
11
+ * Options for bootstrapping a single CDK environment
12
+ */
13
+ export interface BootstrapCDKEnvironmentOptions {
14
+ accountId: string;
15
+ region: string;
16
+ credentials: AWSCredentials;
17
+ }
18
+ /**
19
+ * Result of a CDK bootstrap operation
20
+ */
21
+ export interface BootstrapResult {
22
+ success: boolean;
23
+ output: string;
24
+ }
25
+ /**
26
+ * Options for bootstrapping all environments
27
+ */
28
+ export interface BootstrapAllEnvironmentsOptions {
29
+ accounts: Record<string, string>;
30
+ region: string;
31
+ adminCredentials: AWSCredentials | null;
32
+ spinner: Ora;
33
+ }
34
+ /**
35
+ * Bootstraps AWS CDK in a single environment account
36
+ *
37
+ * Runs `cdk bootstrap` via npx to prepare the account for CDK deployments.
38
+ * Bootstrap creates the necessary CloudFormation stack with S3 bucket and ECR
39
+ * repository for CDK deployment assets.
40
+ *
41
+ * @param options - Bootstrap options including account ID, region, and credentials
42
+ * @returns Promise resolving to bootstrap result with success status and output
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * const result = await bootstrapCDKEnvironment({
47
+ * accountId: '123456789012',
48
+ * region: 'us-west-2',
49
+ * credentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...', sessionToken: 'token...' }
50
+ * });
51
+ * if (!result.success) {
52
+ * console.error('Bootstrap failed:', result.output);
53
+ * }
54
+ * ```
55
+ */
56
+ export declare function bootstrapCDKEnvironment(options: BootstrapCDKEnvironmentOptions): Promise<BootstrapResult>;
57
+ /**
58
+ * Bootstraps AWS CDK in all environment accounts (dev, stage, prod)
59
+ *
60
+ * Iterates through all environments, assumes the OrganizationAccountAccessRole
61
+ * in each account, and runs CDK bootstrap. This prepares all environments for
62
+ * CDK deployments with proper trust relationships and execution policies.
63
+ *
64
+ * @param options - Bootstrap options including account map, region, admin credentials, and spinner
65
+ * @throws Error if bootstrap fails in any environment
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * await bootstrapAllEnvironments({
70
+ * accounts: { dev: '111111111111', stage: '222222222222', prod: '333333333333' },
71
+ * region: 'us-west-2',
72
+ * adminCredentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...' },
73
+ * spinner: ora()
74
+ * });
75
+ * ```
76
+ */
77
+ export declare function bootstrapAllEnvironments(options: BootstrapAllEnvironmentsOptions): Promise<void>;
@@ -0,0 +1,139 @@
1
+ import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
2
+ import { execa } from 'execa';
3
+ import pc from 'picocolors';
4
+ /**
5
+ * AWS CDK Bootstrap module
6
+ *
7
+ * Provides functions to bootstrap AWS CDK in environment accounts,
8
+ * preparing them for CDK deployments with proper trust policies.
9
+ */
10
+ const ENVIRONMENTS = ['dev', 'stage', 'prod'];
11
+ /**
12
+ * Bootstraps AWS CDK in a single environment account
13
+ *
14
+ * Runs `cdk bootstrap` via npx to prepare the account for CDK deployments.
15
+ * Bootstrap creates the necessary CloudFormation stack with S3 bucket and ECR
16
+ * repository for CDK deployment assets.
17
+ *
18
+ * @param options - Bootstrap options including account ID, region, and credentials
19
+ * @returns Promise resolving to bootstrap result with success status and output
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const result = await bootstrapCDKEnvironment({
24
+ * accountId: '123456789012',
25
+ * region: 'us-west-2',
26
+ * credentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...', sessionToken: 'token...' }
27
+ * });
28
+ * if (!result.success) {
29
+ * console.error('Bootstrap failed:', result.output);
30
+ * }
31
+ * ```
32
+ */
33
+ export async function bootstrapCDKEnvironment(options) {
34
+ const { accountId, region, credentials } = options;
35
+ try {
36
+ const args = [
37
+ 'cdk',
38
+ 'bootstrap',
39
+ `aws://${accountId}/${region}`,
40
+ '--trust',
41
+ accountId,
42
+ '--cloudformation-execution-policies',
43
+ 'arn:aws:iam::aws:policy/AdministratorAccess',
44
+ '--require-approval',
45
+ 'never',
46
+ ];
47
+ const env = {
48
+ ...process.env,
49
+ AWS_ACCESS_KEY_ID: credentials.accessKeyId,
50
+ AWS_SECRET_ACCESS_KEY: credentials.secretAccessKey,
51
+ AWS_REGION: region,
52
+ };
53
+ // Add session token if present (for temporary credentials)
54
+ if (credentials.sessionToken) {
55
+ env.AWS_SESSION_TOKEN = credentials.sessionToken;
56
+ }
57
+ const result = await execa('npx', args, {
58
+ all: true,
59
+ env,
60
+ });
61
+ return {
62
+ success: true,
63
+ output: result.all || '',
64
+ };
65
+ }
66
+ catch (error) {
67
+ const execaError = error;
68
+ return {
69
+ success: false,
70
+ output: execaError.all || execaError.message,
71
+ };
72
+ }
73
+ }
74
+ /**
75
+ * Bootstraps AWS CDK in all environment accounts (dev, stage, prod)
76
+ *
77
+ * Iterates through all environments, assumes the OrganizationAccountAccessRole
78
+ * in each account, and runs CDK bootstrap. This prepares all environments for
79
+ * CDK deployments with proper trust relationships and execution policies.
80
+ *
81
+ * @param options - Bootstrap options including account map, region, admin credentials, and spinner
82
+ * @throws Error if bootstrap fails in any environment
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * await bootstrapAllEnvironments({
87
+ * accounts: { dev: '111111111111', stage: '222222222222', prod: '333333333333' },
88
+ * region: 'us-west-2',
89
+ * adminCredentials: { accessKeyId: 'AKIA...', secretAccessKey: 'secret...' },
90
+ * spinner: ora()
91
+ * });
92
+ * ```
93
+ */
94
+ export async function bootstrapAllEnvironments(options) {
95
+ const { accounts, region, adminCredentials, spinner } = options;
96
+ for (const env of ENVIRONMENTS) {
97
+ const accountId = accounts[env];
98
+ if (!accountId) {
99
+ continue;
100
+ }
101
+ spinner.text = `Bootstrapping CDK in ${env} account (${accountId})...`;
102
+ // Get cross-account credentials via STS AssumeRole
103
+ const stsClient = new STSClient({
104
+ region,
105
+ ...(adminCredentials && { credentials: adminCredentials }),
106
+ });
107
+ const assumeRoleCommand = new AssumeRoleCommand({
108
+ RoleArn: `arn:aws:iam::${accountId}:role/OrganizationAccountAccessRole`,
109
+ RoleSessionName: `create-aws-project-bootstrap-${Date.now()}`,
110
+ DurationSeconds: 900,
111
+ });
112
+ const assumeRoleResponse = await stsClient.send(assumeRoleCommand);
113
+ if (!assumeRoleResponse.Credentials?.AccessKeyId ||
114
+ !assumeRoleResponse.Credentials?.SecretAccessKey ||
115
+ !assumeRoleResponse.Credentials?.SessionToken) {
116
+ spinner.fail(`Failed to assume role in ${env} account`);
117
+ throw new Error(`Failed to get temporary credentials for ${env} account`);
118
+ }
119
+ const credentials = {
120
+ accessKeyId: assumeRoleResponse.Credentials.AccessKeyId,
121
+ secretAccessKey: assumeRoleResponse.Credentials.SecretAccessKey,
122
+ sessionToken: assumeRoleResponse.Credentials.SessionToken,
123
+ };
124
+ // Bootstrap the environment
125
+ const result = await bootstrapCDKEnvironment({
126
+ accountId,
127
+ region,
128
+ credentials,
129
+ });
130
+ if (!result.success) {
131
+ spinner.fail(`CDK bootstrap failed in ${env} account`);
132
+ console.error(pc.red('Bootstrap error:'));
133
+ console.error(result.output);
134
+ throw new Error(`CDK bootstrap failed in ${env} account`);
135
+ }
136
+ spinner.succeed(`CDK bootstrapped in ${env} account (${accountId})`);
137
+ }
138
+ console.log(pc.green('All environments bootstrapped for CDK deployments!'));
139
+ }
package/dist/aws/iam.js CHANGED
@@ -210,6 +210,23 @@ function getCDKDeploymentPolicyDocument(accountId) {
210
210
  ],
211
211
  Resource: '*',
212
212
  },
213
+ {
214
+ Sid: 'S3ListBuckets',
215
+ Effect: 'Allow',
216
+ Action: 's3:ListAllMyBuckets',
217
+ Resource: '*',
218
+ },
219
+ {
220
+ Sid: 'AccessWebBucket',
221
+ Effect: 'Allow',
222
+ Action: [
223
+ 's3:*'
224
+ ],
225
+ Resource: [
226
+ `arn:aws:s3:::*-development-web-${accountId}`,
227
+ `arn:aws:s3:::*-development-web-${accountId}/*`,
228
+ ],
229
+ },
213
230
  ],
214
231
  };
215
232
  return JSON.stringify(policy);
@@ -1,4 +1,4 @@
1
- import { OrganizationsClient } from '@aws-sdk/client-organizations';
1
+ import { OrganizationsClient, Account } from '@aws-sdk/client-organizations';
2
2
  /**
3
3
  * AWS Organizations service module
4
4
  *
@@ -17,6 +17,12 @@ export declare function createOrganizationsClient(region?: string): Organization
17
17
  * @returns Organization ID if exists, null if not in an organization
18
18
  */
19
19
  export declare function checkExistingOrganization(client: OrganizationsClient): Promise<string | null>;
20
+ /**
21
+ * Lists all accounts in the AWS Organization with automatic pagination handling
22
+ * @param client - OrganizationsClient instance
23
+ * @returns Array of Account objects from the organization
24
+ */
25
+ export declare function listOrganizationAccounts(client: OrganizationsClient): Promise<Account[]>;
20
26
  /**
21
27
  * Creates a new AWS Organization with all features enabled
22
28
  * @param client - OrganizationsClient instance
@@ -1,4 +1,4 @@
1
- import { OrganizationsClient, CreateOrganizationCommand, CreateAccountCommand, DescribeCreateAccountStatusCommand, DescribeOrganizationCommand, AlreadyInOrganizationException, } from '@aws-sdk/client-organizations';
1
+ import { OrganizationsClient, CreateOrganizationCommand, CreateAccountCommand, DescribeCreateAccountStatusCommand, DescribeOrganizationCommand, AlreadyInOrganizationException, ListAccountsCommand, } from '@aws-sdk/client-organizations';
2
2
  import pc from 'picocolors';
3
3
  /**
4
4
  * AWS Organizations service module
@@ -33,6 +33,24 @@ export async function checkExistingOrganization(client) {
33
33
  throw error;
34
34
  }
35
35
  }
36
+ /**
37
+ * Lists all accounts in the AWS Organization with automatic pagination handling
38
+ * @param client - OrganizationsClient instance
39
+ * @returns Array of Account objects from the organization
40
+ */
41
+ export async function listOrganizationAccounts(client) {
42
+ const accounts = [];
43
+ let nextToken;
44
+ do {
45
+ const command = new ListAccountsCommand({ NextToken: nextToken });
46
+ const response = await client.send(command);
47
+ if (response.Accounts) {
48
+ accounts.push(...response.Accounts);
49
+ }
50
+ nextToken = response.NextToken;
51
+ } while (nextToken);
52
+ return accounts;
53
+ }
36
54
  /**
37
55
  * Creates a new AWS Organization with all features enabled
38
56
  * @param client - OrganizationsClient instance
@@ -0,0 +1,68 @@
1
+ import { IAMClient } from '@aws-sdk/client-iam';
2
+ /**
3
+ * AWS Root Credential Detection and Admin User Management
4
+ *
5
+ * Provides functions to detect root credentials and create/adopt admin IAM users
6
+ * in the management account for cross-account operations.
7
+ */
8
+ /**
9
+ * Caller identity information from STS GetCallerIdentity
10
+ */
11
+ export interface CallerIdentity {
12
+ arn: string;
13
+ accountId: string;
14
+ userId: string;
15
+ isRoot: boolean;
16
+ }
17
+ /**
18
+ * Admin user creation result
19
+ */
20
+ export interface AdminUserResult {
21
+ userName: string;
22
+ accessKeyId: string;
23
+ secretAccessKey: string;
24
+ adopted: boolean;
25
+ }
26
+ /**
27
+ * Checks if an ARN represents a root user
28
+ * @param arn - AWS ARN to check
29
+ * @returns true if ARN ends with :root
30
+ */
31
+ export declare function isRootUser(arn: string): boolean;
32
+ /**
33
+ * Detects if the current AWS credentials are for a root user
34
+ * @param region - AWS region for STS client
35
+ * @returns Caller identity with root status flag
36
+ */
37
+ export declare function detectRootCredentials(region: string): Promise<CallerIdentity>;
38
+ /**
39
+ * Retries a function with exponential backoff
40
+ * @param fn - Async function to retry
41
+ * @param options - Retry configuration
42
+ * @returns Result of the function
43
+ * @throws Last error if all retries fail
44
+ */
45
+ export declare function retryWithBackoff<T>(fn: () => Promise<T>, options?: {
46
+ maxRetries?: number;
47
+ baseDelayMs?: number;
48
+ description?: string;
49
+ }): Promise<T>;
50
+ /**
51
+ * Creates access key for admin user
52
+ * @param client - IAMClient instance
53
+ * @param userName - Admin user name
54
+ * @returns Access key credentials
55
+ * @throws Error if user already has 2 access keys
56
+ */
57
+ export declare function createAccessKeyForAdmin(client: IAMClient, userName: string): Promise<{
58
+ accessKeyId: string;
59
+ secretAccessKey: string;
60
+ }>;
61
+ /**
62
+ * Creates or adopts an admin user in the management account
63
+ * @param client - IAMClient instance
64
+ * @param projectName - Project name for user naming
65
+ * @returns Admin user credentials
66
+ * @throws Error if user exists but not managed by this tool or has existing keys
67
+ */
68
+ export declare function createOrAdoptAdminUser(client: IAMClient, projectName: string): Promise<AdminUserResult>;
@@ -0,0 +1,157 @@
1
+ import { CreateUserCommand, GetUserCommand, AttachUserPolicyCommand, CreateAccessKeyCommand, ListUserTagsCommand, NoSuchEntityException, } from '@aws-sdk/client-iam';
2
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
3
+ import pc from 'picocolors';
4
+ import { getAccessKeyCount } from './iam.js';
5
+ /**
6
+ * Checks if an ARN represents a root user
7
+ * @param arn - AWS ARN to check
8
+ * @returns true if ARN ends with :root
9
+ */
10
+ export function isRootUser(arn) {
11
+ return arn.endsWith(':root');
12
+ }
13
+ /**
14
+ * Detects if the current AWS credentials are for a root user
15
+ * @param region - AWS region for STS client
16
+ * @returns Caller identity with root status flag
17
+ */
18
+ export async function detectRootCredentials(region) {
19
+ const client = new STSClient({ region });
20
+ const command = new GetCallerIdentityCommand({});
21
+ const response = await client.send(command);
22
+ if (!response.Arn || !response.Account || !response.UserId) {
23
+ throw new Error('GetCallerIdentity returned incomplete response');
24
+ }
25
+ return {
26
+ arn: response.Arn,
27
+ accountId: response.Account,
28
+ userId: response.UserId,
29
+ isRoot: isRootUser(response.Arn),
30
+ };
31
+ }
32
+ /**
33
+ * Helper function to sleep for retry backoff
34
+ */
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+ /**
39
+ * Retries a function with exponential backoff
40
+ * @param fn - Async function to retry
41
+ * @param options - Retry configuration
42
+ * @returns Result of the function
43
+ * @throws Last error if all retries fail
44
+ */
45
+ export async function retryWithBackoff(fn, options) {
46
+ const maxRetries = options?.maxRetries ?? 5;
47
+ const baseDelayMs = options?.baseDelayMs ?? 1000;
48
+ const description = options?.description ?? 'operation';
49
+ let lastError;
50
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
51
+ try {
52
+ return await fn();
53
+ }
54
+ catch (error) {
55
+ lastError = error instanceof Error ? error : new Error(String(error));
56
+ if (attempt < maxRetries) {
57
+ const delay = baseDelayMs * Math.pow(2, attempt);
58
+ console.log(pc.dim(` Retrying ${description} (attempt ${attempt + 1}/${maxRetries})...`));
59
+ await sleep(delay);
60
+ }
61
+ }
62
+ }
63
+ throw lastError;
64
+ }
65
+ /**
66
+ * Creates access key for admin user
67
+ * @param client - IAMClient instance
68
+ * @param userName - Admin user name
69
+ * @returns Access key credentials
70
+ * @throws Error if user already has 2 access keys
71
+ */
72
+ export async function createAccessKeyForAdmin(client, userName) {
73
+ // Check access key limit before attempting creation
74
+ const keyCount = await getAccessKeyCount(client, userName);
75
+ if (keyCount >= 2) {
76
+ throw new Error(`IAM user ${userName} already has 2 access keys (AWS maximum). Delete an existing key in AWS Console > IAM > Users > ${userName} > Security credentials before retrying.`);
77
+ }
78
+ const command = new CreateAccessKeyCommand({
79
+ UserName: userName,
80
+ });
81
+ const response = await client.send(command);
82
+ if (!response.AccessKey?.AccessKeyId || !response.AccessKey?.SecretAccessKey) {
83
+ throw new Error('Access key created but credentials not returned');
84
+ }
85
+ console.log(pc.green(` Created access key for ${userName}`));
86
+ return {
87
+ accessKeyId: response.AccessKey.AccessKeyId,
88
+ secretAccessKey: response.AccessKey.SecretAccessKey,
89
+ };
90
+ }
91
+ /**
92
+ * Creates or adopts an admin user in the management account
93
+ * @param client - IAMClient instance
94
+ * @param projectName - Project name for user naming
95
+ * @returns Admin user credentials
96
+ * @throws Error if user exists but not managed by this tool or has existing keys
97
+ */
98
+ export async function createOrAdoptAdminUser(client, projectName) {
99
+ const userName = `${projectName}-admin`;
100
+ // Check if user already exists
101
+ try {
102
+ await client.send(new GetUserCommand({ UserName: userName }));
103
+ // User exists - check if managed by us
104
+ const tagsCommand = new ListUserTagsCommand({ UserName: userName });
105
+ const tagsResponse = await client.send(tagsCommand);
106
+ const isManagedByUs = tagsResponse.Tags?.some((tag) => tag.Key === 'ManagedBy' && tag.Value === 'create-aws-starter-kit') ?? false;
107
+ if (!isManagedByUs) {
108
+ throw new Error(`IAM user "${userName}" exists but was not created by this tool. Delete it or use a different project name.`);
109
+ }
110
+ // Managed by us - check key count
111
+ const keyCount = await getAccessKeyCount(client, userName);
112
+ if (keyCount >= 1) {
113
+ throw new Error(`IAM user "${userName}" already exists with ${keyCount} access key(s). This tool cannot retrieve existing secret keys. Please delete all access keys for this user in AWS Console > IAM > Users > ${userName} > Security credentials, or provide IAM credentials directly instead of using root credentials.`);
114
+ }
115
+ // No keys - create new key and adopt
116
+ console.log(pc.yellow(` Adopting existing admin user: ${userName}`));
117
+ const credentials = await retryWithBackoff(() => createAccessKeyForAdmin(client, userName), { description: 'access key creation' });
118
+ return {
119
+ userName,
120
+ accessKeyId: credentials.accessKeyId,
121
+ secretAccessKey: credentials.secretAccessKey,
122
+ adopted: true,
123
+ };
124
+ }
125
+ catch (error) {
126
+ // User doesn't exist - create new
127
+ if (error instanceof NoSuchEntityException) {
128
+ console.log(pc.cyan(` Creating admin user: ${userName}`));
129
+ // Create user
130
+ await client.send(new CreateUserCommand({
131
+ UserName: userName,
132
+ Path: '/admin/',
133
+ Tags: [
134
+ { Key: 'Purpose', Value: 'CLI Admin' },
135
+ { Key: 'ManagedBy', Value: 'create-aws-starter-kit' },
136
+ ],
137
+ }));
138
+ console.log(pc.green(` Created IAM user: ${userName}`));
139
+ // Attach AdministratorAccess policy
140
+ await client.send(new AttachUserPolicyCommand({
141
+ UserName: userName,
142
+ PolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess',
143
+ }));
144
+ console.log(pc.green(` Attached AdministratorAccess policy`));
145
+ // Create access key with retry for IAM eventual consistency
146
+ const credentials = await retryWithBackoff(() => createAccessKeyForAdmin(client, userName), { description: 'access key creation after user creation' });
147
+ return {
148
+ userName,
149
+ accessKeyId: credentials.accessKeyId,
150
+ secretAccessKey: credentials.secretAccessKey,
151
+ adopted: false,
152
+ };
153
+ }
154
+ // Other error - propagate
155
+ throw error;
156
+ }
157
+ }