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
|
@@ -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
|
+
}
|