create-aws-project 1.5.0 → 1.6.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/README.md CHANGED
@@ -69,6 +69,7 @@ The interactive wizard will ask you about:
69
69
  - VS Code workspace configuration
70
70
  6. **AWS region** - Where to deploy your infrastructure
71
71
  7. **Brand color** - Theme color for your UI (blue, purple, teal, green, orange)
72
+ 8. **GitHub repository** *(optional)* - Provide a repo URL to git init, commit, and push automatically. Press Enter to skip.
72
73
 
73
74
  ## Requirements
74
75
 
@@ -84,32 +85,9 @@ After creating your project, you'll set up AWS environments and GitHub deploymen
84
85
 
85
86
  Before you begin:
86
87
  - AWS CLI configured with credentials from your AWS management account
87
- - GitHub repository created for your project
88
88
  - GitHub Personal Access Token with "repo" scope ([create one here](https://github.com/settings/tokens/new))
89
89
 
90
- ### Step 1: connect to your .git project
91
-
92
- * Initialize the repository
93
- ```
94
- git init
95
- ```
96
-
97
- * Add the remote repository using the git remote add <name> <url> command. A common practice is to name it origin.
98
- ```bash
99
- git remote add origin <REMOTE_URL>
100
- ```
101
-
102
- * Verify the connection by listing your remotes. The -v flag shows the URLs.
103
- ```bash
104
- git remote -v
105
- ```
106
-
107
- * Push your local commits to the remote repository for the first time.
108
- ```bash
109
- git push -u origin main
110
- ```
111
-
112
- ### Step 2: Set Up AWS Environments
90
+ ### Step 1: Set Up AWS Environments
113
91
 
114
92
  From your project directory, run:
115
93
 
@@ -136,7 +114,7 @@ AWS environment setup complete!
136
114
 
137
115
  Account IDs are saved to `.aws-starter-config.json` for the next step.
138
116
 
139
- ### Step 3: Configure GitHub Environments
117
+ ### Step 2: Configure GitHub Environments
140
118
 
141
119
  For each environment, run:
142
120
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2
+ // Mock execa
3
+ const mockExeca = jest.fn();
4
+ jest.unstable_mockModule('execa', () => ({
5
+ execa: mockExeca,
6
+ }));
7
+ // Mock AWS SDK STS client
8
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+ const mockSTSSend = jest.fn();
10
+ const mockAssumeRoleCommand = jest.fn();
11
+ /* eslint-enable @typescript-eslint/no-explicit-any */
12
+ /* eslint-disable @typescript-eslint/no-explicit-any */
13
+ jest.unstable_mockModule('@aws-sdk/client-sts', () => {
14
+ class MockSTSClient {
15
+ send = mockSTSSend;
16
+ }
17
+ return {
18
+ STSClient: MockSTSClient,
19
+ AssumeRoleCommand: mockAssumeRoleCommand,
20
+ };
21
+ });
22
+ /* eslint-enable @typescript-eslint/no-explicit-any */
23
+ // Mock ora (for spinner type)
24
+ const mockOra = {
25
+ text: '',
26
+ succeed: jest.fn(),
27
+ fail: jest.fn(),
28
+ };
29
+ const { bootstrapCDKEnvironment, bootstrapAllEnvironments } = await import('../../aws/cdk-bootstrap.js');
30
+ describe('cdk-bootstrap', () => {
31
+ beforeEach(() => {
32
+ jest.clearAllMocks();
33
+ mockOra.text = '';
34
+ // Mock AssumeRoleCommand to return the input
35
+ mockAssumeRoleCommand.mockImplementation((input) => input);
36
+ });
37
+ describe('bootstrapCDKEnvironment', () => {
38
+ it('calls execa with correct npx cdk bootstrap command', async () => {
39
+ mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
40
+ await bootstrapCDKEnvironment({
41
+ accountId: '123456789012',
42
+ region: 'us-west-2',
43
+ credentials: {
44
+ accessKeyId: 'AKIATEST',
45
+ secretAccessKey: 'secret123',
46
+ sessionToken: 'token123',
47
+ },
48
+ });
49
+ expect(mockExeca).toHaveBeenCalledWith('npx', [
50
+ 'cdk',
51
+ 'bootstrap',
52
+ 'aws://123456789012/us-west-2',
53
+ '--trust',
54
+ '123456789012',
55
+ '--cloudformation-execution-policies',
56
+ 'arn:aws:iam::aws:policy/AdministratorAccess',
57
+ '--require-approval',
58
+ 'never',
59
+ ], expect.objectContaining({
60
+ all: true,
61
+ }));
62
+ });
63
+ it('passes credentials as environment variables', async () => {
64
+ mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
65
+ await bootstrapCDKEnvironment({
66
+ accountId: '123456789012',
67
+ region: 'us-west-2',
68
+ credentials: {
69
+ accessKeyId: 'AKIATEST',
70
+ secretAccessKey: 'secret123',
71
+ sessionToken: 'token123',
72
+ },
73
+ });
74
+ const callArgs = mockExeca.mock.calls[0];
75
+ const options = callArgs[2];
76
+ expect(options.env).toBeDefined();
77
+ expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
78
+ expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
79
+ expect(options.env?.AWS_SESSION_TOKEN).toBe('token123');
80
+ expect(options.env?.AWS_REGION).toBe('us-west-2');
81
+ });
82
+ it('omits session token from env if not provided', async () => {
83
+ mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
84
+ await bootstrapCDKEnvironment({
85
+ accountId: '123456789012',
86
+ region: 'us-west-2',
87
+ credentials: {
88
+ accessKeyId: 'AKIATEST',
89
+ secretAccessKey: 'secret123',
90
+ },
91
+ });
92
+ const callArgs = mockExeca.mock.calls[0];
93
+ const options = callArgs[2];
94
+ expect(options.env).toBeDefined();
95
+ expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
96
+ expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
97
+ expect(options.env?.AWS_SESSION_TOKEN).toBeUndefined();
98
+ expect(options.env?.AWS_REGION).toBe('us-west-2');
99
+ });
100
+ it('returns success result on successful execution', async () => {
101
+ mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
102
+ const result = await bootstrapCDKEnvironment({
103
+ accountId: '123456789012',
104
+ region: 'us-west-2',
105
+ credentials: {
106
+ accessKeyId: 'AKIATEST',
107
+ secretAccessKey: 'secret123',
108
+ },
109
+ });
110
+ expect(result.success).toBe(true);
111
+ expect(result.output).toBe('Bootstrap successful');
112
+ });
113
+ it('returns failure result with output on error', async () => {
114
+ const error = new Error('Bootstrap failed');
115
+ error.all = 'Error: Stack already exists';
116
+ mockExeca.mockRejectedValue(error);
117
+ const result = await bootstrapCDKEnvironment({
118
+ accountId: '123456789012',
119
+ region: 'us-west-2',
120
+ credentials: {
121
+ accessKeyId: 'AKIATEST',
122
+ secretAccessKey: 'secret123',
123
+ },
124
+ });
125
+ expect(result.success).toBe(false);
126
+ expect(result.output).toBe('Error: Stack already exists');
127
+ });
128
+ it('uses error message if all property not available', async () => {
129
+ const error = new Error('Bootstrap failed');
130
+ mockExeca.mockRejectedValue(error);
131
+ const result = await bootstrapCDKEnvironment({
132
+ accountId: '123456789012',
133
+ region: 'us-west-2',
134
+ credentials: {
135
+ accessKeyId: 'AKIATEST',
136
+ secretAccessKey: 'secret123',
137
+ },
138
+ });
139
+ expect(result.success).toBe(false);
140
+ expect(result.output).toBe('Bootstrap failed');
141
+ });
142
+ });
143
+ describe('bootstrapAllEnvironments', () => {
144
+ beforeEach(() => {
145
+ // Mock successful STS AssumeRole responses
146
+ mockSTSSend.mockResolvedValue({
147
+ Credentials: {
148
+ AccessKeyId: 'ASIATEMP123',
149
+ SecretAccessKey: 'tempsecret123',
150
+ SessionToken: 'temptoken123',
151
+ },
152
+ });
153
+ // Mock successful bootstrap
154
+ mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
155
+ });
156
+ it('calls bootstrapCDKEnvironment for each environment', async () => {
157
+ await bootstrapAllEnvironments({
158
+ accounts: {
159
+ dev: '111111111111',
160
+ stage: '222222222222',
161
+ prod: '333333333333',
162
+ },
163
+ region: 'us-east-1',
164
+ adminCredentials: {
165
+ accessKeyId: 'AKIAADMIN',
166
+ secretAccessKey: 'adminsecret',
167
+ },
168
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
169
+ });
170
+ // Should call execa 3 times (once per environment)
171
+ expect(mockExeca).toHaveBeenCalledTimes(3);
172
+ // Verify each environment was bootstrapped
173
+ expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://111111111111/us-east-1']), expect.any(Object));
174
+ expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://222222222222/us-east-1']), expect.any(Object));
175
+ expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://333333333333/us-east-1']), expect.any(Object));
176
+ });
177
+ it('assumes OrganizationAccountAccessRole for each account', async () => {
178
+ await bootstrapAllEnvironments({
179
+ accounts: {
180
+ dev: '111111111111',
181
+ stage: '222222222222',
182
+ prod: '333333333333',
183
+ },
184
+ region: 'us-east-1',
185
+ adminCredentials: {
186
+ accessKeyId: 'AKIAADMIN',
187
+ secretAccessKey: 'adminsecret',
188
+ },
189
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
190
+ });
191
+ // Should call AssumeRoleCommand 3 times (once per environment)
192
+ expect(mockAssumeRoleCommand).toHaveBeenCalledTimes(3);
193
+ // Verify role ARNs for each environment
194
+ const calls = mockAssumeRoleCommand.mock.calls;
195
+ expect(calls[0][0].RoleArn).toBe('arn:aws:iam::111111111111:role/OrganizationAccountAccessRole');
196
+ expect(calls[1][0].RoleArn).toBe('arn:aws:iam::222222222222:role/OrganizationAccountAccessRole');
197
+ expect(calls[2][0].RoleArn).toBe('arn:aws:iam::333333333333:role/OrganizationAccountAccessRole');
198
+ });
199
+ it('updates spinner text for each environment', async () => {
200
+ await bootstrapAllEnvironments({
201
+ accounts: {
202
+ dev: '111111111111',
203
+ stage: '222222222222',
204
+ prod: '333333333333',
205
+ },
206
+ region: 'us-east-1',
207
+ adminCredentials: {
208
+ accessKeyId: 'AKIAADMIN',
209
+ secretAccessKey: 'adminsecret',
210
+ },
211
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
212
+ });
213
+ expect(mockOra.succeed).toHaveBeenCalledTimes(3);
214
+ expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in dev account (111111111111)');
215
+ expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in stage account (222222222222)');
216
+ expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in prod account (333333333333)');
217
+ });
218
+ it('throws error and fails spinner if bootstrap fails', async () => {
219
+ mockExeca.mockResolvedValueOnce({ all: 'Success' }); // dev succeeds
220
+ mockExeca.mockRejectedValueOnce({
221
+ all: 'Error: CDK bootstrap failed',
222
+ message: 'Error: CDK bootstrap failed',
223
+ }); // stage fails
224
+ await expect(bootstrapAllEnvironments({
225
+ accounts: {
226
+ dev: '111111111111',
227
+ stage: '222222222222',
228
+ prod: '333333333333',
229
+ },
230
+ region: 'us-east-1',
231
+ adminCredentials: null,
232
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
233
+ })).rejects.toThrow('CDK bootstrap failed in stage account');
234
+ expect(mockOra.fail).toHaveBeenCalledWith('CDK bootstrap failed in stage account');
235
+ });
236
+ it('works with null adminCredentials (uses default credentials)', async () => {
237
+ await bootstrapAllEnvironments({
238
+ accounts: {
239
+ dev: '111111111111',
240
+ },
241
+ region: 'us-east-1',
242
+ adminCredentials: null,
243
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
244
+ });
245
+ expect(mockSTSSend).toHaveBeenCalled();
246
+ expect(mockExeca).toHaveBeenCalled();
247
+ });
248
+ it('skips environments with missing account IDs', async () => {
249
+ await bootstrapAllEnvironments({
250
+ accounts: {
251
+ dev: '111111111111',
252
+ // stage missing
253
+ prod: '333333333333',
254
+ },
255
+ region: 'us-east-1',
256
+ adminCredentials: {
257
+ accessKeyId: 'AKIAADMIN',
258
+ secretAccessKey: 'adminsecret',
259
+ },
260
+ spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
261
+ });
262
+ // Should only call execa twice (dev and prod, skip stage)
263
+ expect(mockExeca).toHaveBeenCalledTimes(2);
264
+ });
265
+ });
266
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+ // Mock getAccessKeyCount from iam.ts
3
+ const mockGetAccessKeyCount = jest.fn();
4
+ jest.unstable_mockModule('../../aws/iam.js', () => ({
5
+ getAccessKeyCount: mockGetAccessKeyCount,
6
+ }));
7
+ // Mock AWS SDK clients
8
+ /* eslint-disable @typescript-eslint/no-explicit-any */
9
+ const mockSTSSend = jest.fn();
10
+ const mockIAMSend = jest.fn();
11
+ /* eslint-enable @typescript-eslint/no-explicit-any */
12
+ /* eslint-disable @typescript-eslint/no-explicit-any */
13
+ jest.unstable_mockModule('@aws-sdk/client-sts', () => {
14
+ class MockSTSClient {
15
+ send = mockSTSSend;
16
+ }
17
+ return {
18
+ STSClient: MockSTSClient,
19
+ GetCallerIdentityCommand: jest.fn((input) => input),
20
+ };
21
+ });
22
+ jest.unstable_mockModule('@aws-sdk/client-iam', () => {
23
+ class MockIAMClient {
24
+ send = mockIAMSend;
25
+ }
26
+ class NoSuchEntityException extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'NoSuchEntityException';
30
+ }
31
+ }
32
+ return {
33
+ IAMClient: MockIAMClient,
34
+ CreateUserCommand: jest.fn((input) => input),
35
+ GetUserCommand: jest.fn((input) => input),
36
+ AttachUserPolicyCommand: jest.fn((input) => input),
37
+ CreateAccessKeyCommand: jest.fn((input) => input),
38
+ ListUserTagsCommand: jest.fn((input) => input),
39
+ NoSuchEntityException,
40
+ };
41
+ });
42
+ /* eslint-enable @typescript-eslint/no-explicit-any */
43
+ const { isRootUser, detectRootCredentials, retryWithBackoff, createOrAdoptAdminUser, } = await import('../../aws/root-credentials.js');
44
+ describe('root-credentials', () => {
45
+ describe('isRootUser', () => {
46
+ it('returns true for root ARN', () => {
47
+ expect(isRootUser('arn:aws:iam::123456789012:root')).toBe(true);
48
+ });
49
+ it('returns false for IAM user ARN', () => {
50
+ expect(isRootUser('arn:aws:iam::123456789012:user/admin')).toBe(false);
51
+ });
52
+ it('returns false for different IAM user', () => {
53
+ expect(isRootUser('arn:aws:iam::123456789012:user/deploy')).toBe(false);
54
+ });
55
+ it('returns false for assumed role ARN', () => {
56
+ expect(isRootUser('arn:aws:sts::123456789012:assumed-role/role-name/session')).toBe(false);
57
+ });
58
+ it('returns false for empty string', () => {
59
+ expect(isRootUser('')).toBe(false);
60
+ });
61
+ });
62
+ describe('detectRootCredentials', () => {
63
+ beforeEach(() => {
64
+ jest.clearAllMocks();
65
+ });
66
+ it('detects root credentials correctly', async () => {
67
+ mockSTSSend.mockResolvedValueOnce({
68
+ Arn: 'arn:aws:iam::123456789012:root',
69
+ Account: '123456789012',
70
+ UserId: '123456789012',
71
+ });
72
+ const result = await detectRootCredentials('us-east-1');
73
+ expect(result.arn).toBe('arn:aws:iam::123456789012:root');
74
+ expect(result.accountId).toBe('123456789012');
75
+ expect(result.userId).toBe('123456789012');
76
+ expect(result.isRoot).toBe(true);
77
+ });
78
+ it('detects IAM user credentials correctly', async () => {
79
+ mockSTSSend.mockResolvedValueOnce({
80
+ Arn: 'arn:aws:iam::123456789012:user/admin',
81
+ Account: '123456789012',
82
+ UserId: 'AIDACKCEVSQ6C2EXAMPLE',
83
+ });
84
+ const result = await detectRootCredentials('us-east-1');
85
+ expect(result.isRoot).toBe(false);
86
+ });
87
+ it('propagates errors from STS', async () => {
88
+ mockSTSSend.mockRejectedValueOnce(new Error('Access denied'));
89
+ await expect(detectRootCredentials('us-east-1')).rejects.toThrow('Access denied');
90
+ });
91
+ });
92
+ describe('retryWithBackoff', () => {
93
+ beforeEach(() => {
94
+ jest.useFakeTimers();
95
+ });
96
+ afterEach(() => {
97
+ jest.useRealTimers();
98
+ });
99
+ it('returns result on first success', async () => {
100
+ const fn = jest.fn().mockResolvedValue('success');
101
+ const promise = retryWithBackoff(fn, { baseDelayMs: 10 });
102
+ await jest.runAllTimersAsync();
103
+ const result = await promise;
104
+ expect(result).toBe('success');
105
+ expect(fn).toHaveBeenCalledTimes(1);
106
+ });
107
+ it('retries on failure and eventually succeeds', async () => {
108
+ const fn = jest.fn()
109
+ .mockRejectedValueOnce(new Error('Fail 1'))
110
+ .mockRejectedValueOnce(new Error('Fail 2'))
111
+ .mockResolvedValue('success');
112
+ const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 5 });
113
+ await jest.runAllTimersAsync();
114
+ const result = await promise;
115
+ expect(result).toBe('success');
116
+ expect(fn).toHaveBeenCalledTimes(3);
117
+ });
118
+ it('throws last error after all retries fail', async () => {
119
+ const fn = jest.fn();
120
+ fn.mockRejectedValue(new Error('Permanent failure'));
121
+ const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 2 });
122
+ jest.runAllTimersAsync();
123
+ try {
124
+ await promise;
125
+ throw new Error('Should have thrown');
126
+ }
127
+ catch (error) {
128
+ expect(error).toBeInstanceOf(Error);
129
+ expect(error.message).toBe('Permanent failure');
130
+ }
131
+ expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
132
+ });
133
+ it('respects maxRetries option', async () => {
134
+ const fn = jest.fn();
135
+ fn.mockRejectedValue(new Error('Always fails'));
136
+ const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 3 });
137
+ jest.runAllTimersAsync();
138
+ try {
139
+ await promise;
140
+ throw new Error('Should have thrown');
141
+ }
142
+ catch (error) {
143
+ expect(error).toBeInstanceOf(Error);
144
+ expect(error.message).toBe('Always fails');
145
+ }
146
+ expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries
147
+ });
148
+ });
149
+ describe('createOrAdoptAdminUser', () => {
150
+ beforeEach(() => {
151
+ jest.clearAllMocks();
152
+ mockGetAccessKeyCount.mockReset();
153
+ });
154
+ it('creates new admin user when user does not exist', async () => {
155
+ // Import the mocked IAMClient and exception class
156
+ const { IAMClient, NoSuchEntityException } = await import('@aws-sdk/client-iam');
157
+ const mockIAMClient = new IAMClient({ region: 'us-east-1' });
158
+ // Use the mocked NoSuchEntityException class
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ const noSuchEntityError = new NoSuchEntityException('User does not exist');
161
+ mockIAMSend
162
+ .mockRejectedValueOnce(noSuchEntityError) // GetUserCommand throws
163
+ .mockResolvedValueOnce({}) // CreateUserCommand succeeds
164
+ .mockResolvedValueOnce({}) // AttachUserPolicyCommand succeeds
165
+ .mockResolvedValueOnce({
166
+ // CreateAccessKeyCommand succeeds
167
+ AccessKey: {
168
+ AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
169
+ SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
170
+ },
171
+ });
172
+ mockGetAccessKeyCount.mockResolvedValue(0);
173
+ const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
174
+ expect(result.userName).toBe('test-project-admin');
175
+ expect(result.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE');
176
+ expect(result.secretAccessKey).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
177
+ expect(result.adopted).toBe(false);
178
+ // Verify commands were called in order
179
+ expect(mockIAMSend).toHaveBeenCalledTimes(4);
180
+ });
181
+ it('adopts existing managed user with no keys', async () => {
182
+ const { IAMClient } = await import('@aws-sdk/client-iam');
183
+ const mockIAMClient = new IAMClient({ region: 'us-east-1' });
184
+ mockIAMSend
185
+ .mockResolvedValueOnce({}) // GetUserCommand succeeds
186
+ .mockResolvedValueOnce({
187
+ // ListUserTagsCommand returns ManagedBy tag
188
+ Tags: [
189
+ { Key: 'ManagedBy', Value: 'create-aws-starter-kit' },
190
+ { Key: 'Purpose', Value: 'CLI Admin' },
191
+ ],
192
+ })
193
+ .mockResolvedValueOnce({
194
+ // CreateAccessKeyCommand succeeds
195
+ AccessKey: {
196
+ AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
197
+ SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
198
+ },
199
+ });
200
+ mockGetAccessKeyCount.mockResolvedValue(0);
201
+ const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
202
+ expect(result.userName).toBe('test-project-admin');
203
+ expect(result.adopted).toBe(true);
204
+ expect(mockIAMSend).toHaveBeenCalledTimes(3);
205
+ });
206
+ it('throws error when existing user is not managed by us', async () => {
207
+ const { IAMClient } = await import('@aws-sdk/client-iam');
208
+ const mockIAMClient = new IAMClient({ region: 'us-east-1' });
209
+ mockIAMSend
210
+ .mockResolvedValueOnce({}) // GetUserCommand succeeds
211
+ .mockResolvedValueOnce({
212
+ // ListUserTagsCommand returns different tag
213
+ Tags: [{ Key: 'Owner', Value: 'SomeoneElse' }],
214
+ });
215
+ await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" exists but was not created by this tool');
216
+ });
217
+ it('throws error when existing managed user has keys', async () => {
218
+ const { IAMClient } = await import('@aws-sdk/client-iam');
219
+ const mockIAMClient = new IAMClient({ region: 'us-east-1' });
220
+ mockIAMSend
221
+ .mockResolvedValueOnce({}) // GetUserCommand succeeds
222
+ .mockResolvedValueOnce({
223
+ // ListUserTagsCommand returns ManagedBy tag
224
+ Tags: [{ Key: 'ManagedBy', Value: 'create-aws-starter-kit' }],
225
+ });
226
+ mockGetAccessKeyCount.mockResolvedValue(1);
227
+ await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" already exists with 1 access key');
228
+ });
229
+ });
230
+ });
@@ -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>;