create-aws-project 1.6.0 → 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__/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/cli.js +44 -0
- package/dist/commands/setup-aws-envs.d.ts +2 -2
- package/dist/commands/setup-aws-envs.js +308 -2
- 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/package.json +4 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { loadSetupAwsEnvsConfig, deriveEnvironmentEmails } = await import('../../config/non-interactive-aws.js');
|
|
21
|
+
// Helper to write a temp config file and return its path
|
|
22
|
+
function writeTempConfig(content) {
|
|
23
|
+
const tmpFile = join(tmpdir(), `non-interactive-aws-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('deriveEnvironmentEmails', () => {
|
|
28
|
+
it('derives standard dev/stage/prod emails from a simple address', () => {
|
|
29
|
+
const result = deriveEnvironmentEmails('owner@example.com', ['dev', 'stage', 'prod']);
|
|
30
|
+
expect(result.dev).toBe('owner-dev@example.com');
|
|
31
|
+
expect(result.stage).toBe('owner-stage@example.com');
|
|
32
|
+
expect(result.prod).toBe('owner-prod@example.com');
|
|
33
|
+
});
|
|
34
|
+
it('handles plus alias emails', () => {
|
|
35
|
+
const result = deriveEnvironmentEmails('user+tag@company.com', ['dev']);
|
|
36
|
+
expect(result.dev).toBe('user+tag-dev@company.com');
|
|
37
|
+
});
|
|
38
|
+
it('handles subdomain emails', () => {
|
|
39
|
+
const result = deriveEnvironmentEmails('admin@sub.example.com', ['dev']);
|
|
40
|
+
expect(result.dev).toBe('admin-dev@sub.example.com');
|
|
41
|
+
});
|
|
42
|
+
it('returns an empty object for an empty environments array', () => {
|
|
43
|
+
const result = deriveEnvironmentEmails('owner@example.com', []);
|
|
44
|
+
expect(result).toEqual({});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('loadSetupAwsEnvsConfig', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Mock process.exit to throw so tests can catch exit calls
|
|
50
|
+
jest.spyOn(process, 'exit').mockImplementation((() => {
|
|
51
|
+
throw new Error('process.exit called');
|
|
52
|
+
}));
|
|
53
|
+
// Suppress console.error output during tests
|
|
54
|
+
jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
55
|
+
});
|
|
56
|
+
describe('schema validation (valid configs)', () => {
|
|
57
|
+
it('loads valid config with email field only', () => {
|
|
58
|
+
const tmpFile = writeTempConfig({ email: 'owner@example.com' });
|
|
59
|
+
const config = loadSetupAwsEnvsConfig(tmpFile);
|
|
60
|
+
expect(config.email).toBe('owner@example.com');
|
|
61
|
+
});
|
|
62
|
+
it('strips unknown keys silently', () => {
|
|
63
|
+
const tmpFile = writeTempConfig({ email: 'owner@example.com', unknown: 'value', extra: 42 });
|
|
64
|
+
const config = loadSetupAwsEnvsConfig(tmpFile);
|
|
65
|
+
expect(config.email).toBe('owner@example.com');
|
|
66
|
+
expect(config.unknown).toBeUndefined();
|
|
67
|
+
expect(config.extra).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('schema validation (invalid configs)', () => {
|
|
71
|
+
it('exits with error when email is missing', () => {
|
|
72
|
+
const tmpFile = writeTempConfig({});
|
|
73
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
74
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
75
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('email'));
|
|
76
|
+
});
|
|
77
|
+
it('exits with error when email is empty string', () => {
|
|
78
|
+
const tmpFile = writeTempConfig({ email: '' });
|
|
79
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
80
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
81
|
+
});
|
|
82
|
+
it('exits with error when email has no @ sign', () => {
|
|
83
|
+
const tmpFile = writeTempConfig({ email: 'notanemail' });
|
|
84
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
85
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
86
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('email'));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('file errors', () => {
|
|
90
|
+
it('exits with error for non-existent file', () => {
|
|
91
|
+
expect(() => loadSetupAwsEnvsConfig('/nonexistent/path/aws-config.json')).toThrow('process.exit called');
|
|
92
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
93
|
+
});
|
|
94
|
+
it('exits with error for invalid JSON content', () => {
|
|
95
|
+
const tmpFile = writeTempConfig('not json {{{');
|
|
96
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
97
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { showDeprecationNotice } from './commands/setup-github.js';
|
|
|
8
8
|
import { runSetupAwsEnvs } from './commands/setup-aws-envs.js';
|
|
9
9
|
import { runInitializeGitHub } from './commands/initialize-github.js';
|
|
10
10
|
import { promptGitSetup, setupGitRepository } from './git/setup.js';
|
|
11
|
+
import { loadNonInteractiveConfig } from './config/non-interactive.js';
|
|
11
12
|
/**
|
|
12
13
|
* Write project configuration file for downstream commands
|
|
13
14
|
*/
|
|
@@ -54,9 +55,11 @@ Commands:
|
|
|
54
55
|
Options:
|
|
55
56
|
--help, -h Show this help message
|
|
56
57
|
--version, -v Show version number
|
|
58
|
+
--config <path> Create project from JSON config file (non-interactive)
|
|
57
59
|
|
|
58
60
|
Usage:
|
|
59
61
|
create-aws-project Run interactive wizard
|
|
62
|
+
create-aws-project --config project.json Non-interactive mode
|
|
60
63
|
create-aws-project setup-aws-envs Create AWS accounts
|
|
61
64
|
create-aws-project initialize-github dev Configure dev environment
|
|
62
65
|
create-aws-project initialize-github --all Configure all environments
|
|
@@ -64,6 +67,7 @@ Usage:
|
|
|
64
67
|
|
|
65
68
|
Examples:
|
|
66
69
|
create-aws-project my-app
|
|
70
|
+
create-aws-project --config project.json
|
|
67
71
|
create-aws-project setup-aws-envs
|
|
68
72
|
create-aws-project initialize-github dev
|
|
69
73
|
create-aws-project initialize-github --all
|
|
@@ -101,6 +105,33 @@ function printNextSteps(projectName) {
|
|
|
101
105
|
console.log('');
|
|
102
106
|
console.log(pc.gray('Happy coding!'));
|
|
103
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Run project creation in non-interactive mode from a JSON config file.
|
|
110
|
+
* Skips all prompts and git setup (NI-02, NI-06).
|
|
111
|
+
*/
|
|
112
|
+
async function runNonInteractive(configPath) {
|
|
113
|
+
const config = loadNonInteractiveConfig(configPath);
|
|
114
|
+
const outputDir = resolve(process.cwd(), config.projectName);
|
|
115
|
+
// Check if directory already exists
|
|
116
|
+
if (existsSync(outputDir)) {
|
|
117
|
+
console.error(pc.red('Error:') + ` Directory ${pc.cyan(config.projectName)} already exists.`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
// Create project directory
|
|
121
|
+
mkdirSync(outputDir, { recursive: true });
|
|
122
|
+
// Generate project
|
|
123
|
+
console.log('');
|
|
124
|
+
await generateProject(config, outputDir);
|
|
125
|
+
// Write config file for downstream commands
|
|
126
|
+
writeConfigFile(outputDir, config);
|
|
127
|
+
// NI-06: Skip git setup entirely in non-interactive mode
|
|
128
|
+
// promptGitSetup() is NOT called
|
|
129
|
+
// Success message and next steps
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(pc.green('✔') + ` Created ${pc.bold(config.projectName)} successfully!`);
|
|
132
|
+
printNextSteps(config.projectName);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
104
135
|
/**
|
|
105
136
|
* Run the create project wizard flow
|
|
106
137
|
* This is the default command when no subcommand is specified
|
|
@@ -108,6 +139,19 @@ function printNextSteps(projectName) {
|
|
|
108
139
|
async function runCreate(args) {
|
|
109
140
|
printWelcome();
|
|
110
141
|
console.log(''); // blank line after banner
|
|
142
|
+
// Non-interactive path: --config flag provided
|
|
143
|
+
const configFlagIndex = args.findIndex(arg => arg === '--config');
|
|
144
|
+
if (configFlagIndex !== -1) {
|
|
145
|
+
const configPath = args[configFlagIndex + 1];
|
|
146
|
+
if (!configPath || configPath.startsWith('--')) {
|
|
147
|
+
console.error(pc.red('Error:') + ' --config requires a path argument');
|
|
148
|
+
console.error(' Example: npx create-aws-project --config project.json');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
await runNonInteractive(configPath);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Interactive path (unchanged — existing code below)
|
|
111
155
|
// Extract project name from CLI args (first non-flag argument)
|
|
112
156
|
const nameArg = args.find(arg => !arg.startsWith('-'));
|
|
113
157
|
const config = await runWizard(nameArg ? { defaultName: nameArg } : undefined);
|
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Creates AWS Organizations and environment accounts (dev, stage, prod)
|
|
11
11
|
*
|
|
12
|
-
* @param
|
|
12
|
+
* @param args Command arguments
|
|
13
13
|
*/
|
|
14
|
-
export declare function runSetupAwsEnvs(
|
|
14
|
+
export declare function runSetupAwsEnvs(args: string[]): Promise<void>;
|
|
@@ -9,6 +9,7 @@ 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 { loadSetupAwsEnvsConfig, deriveEnvironmentEmails } from '../config/non-interactive-aws.js';
|
|
12
13
|
import { createOrganizationsClient, checkExistingOrganization, createOrganization, createAccount, waitForAccountCreation, listOrganizationAccounts, } from '../aws/organizations.js';
|
|
13
14
|
import { createIAMClient, createCrossAccountIAMClient, createOrAdoptDeploymentUser, createCDKDeploymentPolicy, attachPolicyToUser, createAccessKey, } from '../aws/iam.js';
|
|
14
15
|
import { bootstrapAllEnvironments } from '../aws/cdk-bootstrap.js';
|
|
@@ -190,14 +191,319 @@ function handleAwsError(error) {
|
|
|
190
191
|
}
|
|
191
192
|
process.exit(1);
|
|
192
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
|
+
}
|
|
193
487
|
/**
|
|
194
488
|
* Runs the setup-aws-envs command
|
|
195
489
|
*
|
|
196
490
|
* Creates AWS Organizations and environment accounts (dev, stage, prod)
|
|
197
491
|
*
|
|
198
|
-
* @param
|
|
492
|
+
* @param args Command arguments
|
|
199
493
|
*/
|
|
200
|
-
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
|
+
}
|
|
201
507
|
// 1. Validate we're in a project directory
|
|
202
508
|
const context = await requireProjectContext();
|
|
203
509
|
const { config, configPath } = context;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Zod schema for the non-interactive JSON config file for setup-aws-envs.
|
|
4
|
+
* Only `email` is required — all other AWS setup config lives in .aws-starter-config.json.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SetupAwsEnvsConfigSchema: z.ZodObject<{
|
|
7
|
+
email: z.ZodString;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export type SetupAwsEnvsConfig = z.infer<typeof SetupAwsEnvsConfigSchema>;
|
|
10
|
+
/**
|
|
11
|
+
* Load, validate, and return a SetupAwsEnvsConfig from a JSON config file path.
|
|
12
|
+
* Exits with code 1 and prints all errors if validation fails.
|
|
13
|
+
* Also exits with code 1 if the email does not contain an '@' sign.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadSetupAwsEnvsConfig(configPath: string): SetupAwsEnvsConfig;
|
|
16
|
+
/**
|
|
17
|
+
* Derive per-environment email addresses from a root email.
|
|
18
|
+
* Inserts -{env} between the local part and the domain.
|
|
19
|
+
*
|
|
20
|
+
* Example:
|
|
21
|
+
* deriveEnvironmentEmails('owner@example.com', ['dev', 'stage', 'prod'])
|
|
22
|
+
* → { dev: 'owner-dev@example.com', stage: 'owner-stage@example.com', prod: 'owner-prod@example.com' }
|
|
23
|
+
*
|
|
24
|
+
* Handles plus aliases: 'user+tag@company.com' → 'user+tag-dev@company.com'
|
|
25
|
+
* Handles subdomains: 'admin@sub.example.com' → 'admin-dev@sub.example.com'
|
|
26
|
+
*/
|
|
27
|
+
export declare function deriveEnvironmentEmails(rootEmail: string, environments: readonly string[]): Record<string, string>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
/**
|
|
6
|
+
* Zod schema for the non-interactive JSON config file for setup-aws-envs.
|
|
7
|
+
* Only `email` is required — all other AWS setup config lives in .aws-starter-config.json.
|
|
8
|
+
*/
|
|
9
|
+
export const SetupAwsEnvsConfigSchema = z.object({
|
|
10
|
+
email: z.string().min(1, { message: 'email is required' }),
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Load, validate, and return a SetupAwsEnvsConfig from a JSON config file path.
|
|
14
|
+
* Exits with code 1 and prints all errors if validation fails.
|
|
15
|
+
* Also exits with code 1 if the email does not contain an '@' sign.
|
|
16
|
+
*/
|
|
17
|
+
export function loadSetupAwsEnvsConfig(configPath) {
|
|
18
|
+
// 1. Resolve path relative to cwd
|
|
19
|
+
const absolutePath = resolve(process.cwd(), configPath);
|
|
20
|
+
// 2. Read file — fail fast if not found or unreadable
|
|
21
|
+
let rawContent;
|
|
22
|
+
try {
|
|
23
|
+
rawContent = readFileSync(absolutePath, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
console.error(pc.red('Error:') + ` Cannot read config file: ${absolutePath}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
// 3. Parse JSON — fail fast if invalid
|
|
30
|
+
let rawData;
|
|
31
|
+
try {
|
|
32
|
+
rawData = JSON.parse(rawContent);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
console.error(pc.red('Error:') + ` Config file is not valid JSON: ${absolutePath}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
// 4. Validate with Zod — collect ALL errors in one pass
|
|
39
|
+
const result = SetupAwsEnvsConfigSchema.safeParse(rawData);
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
console.error(pc.red('Error:') + ' Invalid config file:');
|
|
42
|
+
console.error('');
|
|
43
|
+
for (const issue of result.error.issues) {
|
|
44
|
+
const fieldPath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
45
|
+
console.error(` ${pc.red('x')} ${fieldPath}: ${issue.message}`);
|
|
46
|
+
}
|
|
47
|
+
console.error('');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
// 5. Additional email format check — must contain '@' for derivation to work correctly
|
|
51
|
+
if (!result.data.email.includes('@')) {
|
|
52
|
+
console.error(pc.red('Error:') + ' Invalid config file:');
|
|
53
|
+
console.error('');
|
|
54
|
+
console.error(` ${pc.red('x')} email: must be a valid email address`);
|
|
55
|
+
console.error('');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return result.data;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Derive per-environment email addresses from a root email.
|
|
62
|
+
* Inserts -{env} between the local part and the domain.
|
|
63
|
+
*
|
|
64
|
+
* Example:
|
|
65
|
+
* deriveEnvironmentEmails('owner@example.com', ['dev', 'stage', 'prod'])
|
|
66
|
+
* → { dev: 'owner-dev@example.com', stage: 'owner-stage@example.com', prod: 'owner-prod@example.com' }
|
|
67
|
+
*
|
|
68
|
+
* Handles plus aliases: 'user+tag@company.com' → 'user+tag-dev@company.com'
|
|
69
|
+
* Handles subdomains: 'admin@sub.example.com' → 'admin-dev@sub.example.com'
|
|
70
|
+
*/
|
|
71
|
+
export function deriveEnvironmentEmails(rootEmail, environments) {
|
|
72
|
+
const atIndex = rootEmail.lastIndexOf('@');
|
|
73
|
+
const localPart = rootEmail.slice(0, atIndex);
|
|
74
|
+
const domain = rootEmail.slice(atIndex); // includes '@'
|
|
75
|
+
const derived = {};
|
|
76
|
+
for (const env of environments) {
|
|
77
|
+
derived[env] = `${localPart}-${env}${domain}`;
|
|
78
|
+
}
|
|
79
|
+
return derived;
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import type { ProjectConfig } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Zod schema for the non-interactive JSON config file.
|
|
5
|
+
* Only `name` is required; all other fields have defaults matching NI-04 spec.
|
|
6
|
+
*/
|
|
7
|
+
export declare const NonInteractiveConfigSchema: z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
platforms: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
10
|
+
web: "web";
|
|
11
|
+
mobile: "mobile";
|
|
12
|
+
api: "api";
|
|
13
|
+
}>>>;
|
|
14
|
+
auth: z.ZodDefault<z.ZodEnum<{
|
|
15
|
+
cognito: "cognito";
|
|
16
|
+
auth0: "auth0";
|
|
17
|
+
none: "none";
|
|
18
|
+
}>>;
|
|
19
|
+
authFeatures: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
20
|
+
"social-login": "social-login";
|
|
21
|
+
mfa: "mfa";
|
|
22
|
+
}>>>;
|
|
23
|
+
features: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
24
|
+
"github-actions": "github-actions";
|
|
25
|
+
"vscode-config": "vscode-config";
|
|
26
|
+
}>>>;
|
|
27
|
+
region: z.ZodDefault<z.ZodEnum<{
|
|
28
|
+
"us-east-1": "us-east-1";
|
|
29
|
+
"us-west-2": "us-west-2";
|
|
30
|
+
"eu-west-1": "eu-west-1";
|
|
31
|
+
"eu-central-1": "eu-central-1";
|
|
32
|
+
"ap-northeast-1": "ap-northeast-1";
|
|
33
|
+
"ap-southeast-2": "ap-southeast-2";
|
|
34
|
+
}>>;
|
|
35
|
+
brandColor: z.ZodDefault<z.ZodEnum<{
|
|
36
|
+
blue: "blue";
|
|
37
|
+
purple: "purple";
|
|
38
|
+
teal: "teal";
|
|
39
|
+
green: "green";
|
|
40
|
+
orange: "orange";
|
|
41
|
+
}>>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export type NonInteractiveConfig = z.infer<typeof NonInteractiveConfigSchema>;
|
|
44
|
+
/**
|
|
45
|
+
* Load, validate, and return a ProjectConfig from a JSON config file path.
|
|
46
|
+
* Exits with code 1 and prints all errors if validation fails.
|
|
47
|
+
*/
|
|
48
|
+
export declare function loadNonInteractiveConfig(configPath: string): ProjectConfig;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { validateProjectName } from '../validation/project-name.js';
|
|
6
|
+
// Valid value constants (as const tuples for Zod enum compatibility)
|
|
7
|
+
const VALID_PLATFORMS = ['web', 'mobile', 'api'];
|
|
8
|
+
const VALID_AUTH_PROVIDERS = ['none', 'cognito', 'auth0'];
|
|
9
|
+
const VALID_AUTH_FEATURES = ['social-login', 'mfa'];
|
|
10
|
+
const VALID_FEATURES = ['github-actions', 'vscode-config'];
|
|
11
|
+
const VALID_REGIONS = [
|
|
12
|
+
'us-east-1',
|
|
13
|
+
'us-west-2',
|
|
14
|
+
'eu-west-1',
|
|
15
|
+
'eu-central-1',
|
|
16
|
+
'ap-northeast-1',
|
|
17
|
+
'ap-southeast-2',
|
|
18
|
+
];
|
|
19
|
+
const VALID_BRAND_COLORS = ['blue', 'purple', 'teal', 'green', 'orange'];
|
|
20
|
+
/**
|
|
21
|
+
* Zod schema for the non-interactive JSON config file.
|
|
22
|
+
* Only `name` is required; all other fields have defaults matching NI-04 spec.
|
|
23
|
+
*/
|
|
24
|
+
export const NonInteractiveConfigSchema = z.object({
|
|
25
|
+
name: z.string().min(1, { message: 'name is required' }),
|
|
26
|
+
platforms: z.array(z.enum(VALID_PLATFORMS)).min(1).default(['web', 'api']),
|
|
27
|
+
auth: z.enum(VALID_AUTH_PROVIDERS).default('none'),
|
|
28
|
+
authFeatures: z.array(z.enum(VALID_AUTH_FEATURES)).default([]),
|
|
29
|
+
features: z.array(z.enum(VALID_FEATURES)).default(['github-actions', 'vscode-config']),
|
|
30
|
+
region: z.enum(VALID_REGIONS).default('us-east-1'),
|
|
31
|
+
brandColor: z.enum(VALID_BRAND_COLORS).default('blue'),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Load, validate, and return a ProjectConfig from a JSON config file path.
|
|
35
|
+
* Exits with code 1 and prints all errors if validation fails.
|
|
36
|
+
*/
|
|
37
|
+
export function loadNonInteractiveConfig(configPath) {
|
|
38
|
+
// 1. Resolve path relative to cwd
|
|
39
|
+
const absolutePath = resolve(process.cwd(), configPath);
|
|
40
|
+
// 2. Read file — fail fast if not found or unreadable
|
|
41
|
+
let rawContent;
|
|
42
|
+
try {
|
|
43
|
+
rawContent = readFileSync(absolutePath, 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
console.error(pc.red('Error:') + ` Cannot read config file: ${absolutePath}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
// 3. Parse JSON — fail fast if invalid
|
|
50
|
+
let rawData;
|
|
51
|
+
try {
|
|
52
|
+
rawData = JSON.parse(rawContent);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
console.error(pc.red('Error:') + ` Config file is not valid JSON: ${absolutePath}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// 4. Validate with Zod — collect ALL errors in one pass
|
|
59
|
+
const result = NonInteractiveConfigSchema.safeParse(rawData);
|
|
60
|
+
if (!result.success) {
|
|
61
|
+
console.error(pc.red('Error:') + ' Invalid config file:');
|
|
62
|
+
console.error('');
|
|
63
|
+
for (const issue of result.error.issues) {
|
|
64
|
+
const fieldPath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
65
|
+
console.error(` ${pc.red('x')} ${fieldPath}: ${issue.message}`);
|
|
66
|
+
}
|
|
67
|
+
console.error('');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const cfg = result.data;
|
|
71
|
+
// 5. Additional project name validation via existing npm package name validator
|
|
72
|
+
const nameValidation = validateProjectName(cfg.name);
|
|
73
|
+
if (nameValidation !== true) {
|
|
74
|
+
console.error(pc.red('Error:') + ' Invalid config file:');
|
|
75
|
+
console.error('');
|
|
76
|
+
console.error(` ${pc.red('x')} name: ${nameValidation}`);
|
|
77
|
+
console.error('');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
// 6. Map to ProjectConfig — silently drop authFeatures when auth is 'none'
|
|
81
|
+
return {
|
|
82
|
+
projectName: cfg.name,
|
|
83
|
+
platforms: cfg.platforms,
|
|
84
|
+
awsRegion: cfg.region,
|
|
85
|
+
features: cfg.features,
|
|
86
|
+
brandColor: cfg.brandColor,
|
|
87
|
+
auth: {
|
|
88
|
+
provider: cfg.auth,
|
|
89
|
+
features: cfg.auth === 'none' ? [] : cfg.authFeatures,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-aws-project",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "CLI tool to scaffold AWS projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,11 +52,12 @@
|
|
|
52
52
|
"@aws-sdk/credential-providers": "^3.971.0",
|
|
53
53
|
"@octokit/rest": "^22.0.1",
|
|
54
54
|
"find-up": "^8.0.0",
|
|
55
|
+
"libsodium-wrappers": "^0.7.15",
|
|
55
56
|
"ora": "^9.1.0",
|
|
56
57
|
"picocolors": "^1.1.1",
|
|
57
58
|
"prompts": "^2.4.2",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
59
|
+
"validate-npm-package-name": "^7.0.2",
|
|
60
|
+
"zod": "^4.3.6"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/jest": "^30.0.0",
|