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.
@@ -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 _args Command arguments (unused)
12
+ * @param args Command arguments
13
13
  */
14
- export declare function runSetupAwsEnvs(_args: string[]): Promise<void>;
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 _args Command arguments (unused)
492
+ * @param args Command arguments
199
493
  */
200
- export async function runSetupAwsEnvs(_args) {
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.6.0",
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
- "libsodium-wrappers": "^0.7.15",
59
- "validate-npm-package-name": "^7.0.2"
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",