@aws/ml-container-creator 0.2.1 → 0.2.3

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.
Files changed (36) hide show
  1. package/bin/cli.js +88 -86
  2. package/config/bootstrap-stack.json +211 -0
  3. package/config/parameter-schema.json +88 -0
  4. package/infra/ci-harness/bin/ci-harness.ts +26 -0
  5. package/infra/ci-harness/buildspec.yml +352 -0
  6. package/infra/ci-harness/cdk.json +27 -0
  7. package/infra/ci-harness/lambda/scanner/index.ts +199 -0
  8. package/infra/ci-harness/lib/ci-harness-stack.ts +609 -0
  9. package/infra/ci-harness/package-lock.json +3979 -0
  10. package/infra/ci-harness/package.json +32 -0
  11. package/infra/ci-harness/tsconfig.json +38 -0
  12. package/package.json +13 -3
  13. package/src/app.js +318 -318
  14. package/src/copy-tpl.js +19 -19
  15. package/src/lib/asset-manager.js +74 -74
  16. package/src/lib/aws-profile-parser.js +45 -45
  17. package/src/lib/bootstrap-command-handler.js +560 -547
  18. package/src/lib/bootstrap-config.js +45 -45
  19. package/src/lib/ci-register-helpers.js +19 -19
  20. package/src/lib/ci-report-helpers.js +37 -37
  21. package/src/lib/ci-stage-helpers.js +49 -49
  22. package/src/lib/comment-generator.js +4 -4
  23. package/src/lib/config-manager.js +105 -105
  24. package/src/lib/deployment-config-resolver.js +10 -10
  25. package/src/lib/deployment-registry.js +153 -153
  26. package/src/lib/engine-prefix-resolver.js +8 -8
  27. package/src/lib/key-value-parser.js +6 -6
  28. package/src/lib/manifest-cli.js +108 -108
  29. package/src/lib/prompt-runner.js +224 -224
  30. package/src/lib/prompts.js +121 -121
  31. package/src/lib/registry-command-handler.js +174 -174
  32. package/src/lib/registry-loader.js +52 -52
  33. package/src/lib/sensitive-redactor.js +9 -9
  34. package/src/lib/template-engine.js +1 -1
  35. package/src/lib/template-manager.js +62 -62
  36. package/src/prompt-adapter.js +18 -18
@@ -16,27 +16,27 @@
16
16
  * remove <profile> [--force] Remove a bootstrap profile
17
17
  */
18
18
 
19
- import { execSync } from 'node:child_process'
20
- import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'
21
- import path from 'node:path'
22
- import { tmpdir } from 'node:os'
23
- import { fileURLToPath } from 'node:url'
24
- import BootstrapConfig from './bootstrap-config.js'
25
- import AwsProfileParser from './aws-profile-parser.js'
26
- import AssetManager from './asset-manager.js'
27
- import { runPrompts } from '../prompt-adapter.js'
28
-
29
- const __filename = fileURLToPath(import.meta.url)
30
- const __dirname = path.dirname(__filename)
31
-
32
- const STACK_NAME_PREFIX = 'mlcc-bootstrap'
33
- const STACK_TEMPLATE_PATH = path.resolve(__dirname, '../../config/bootstrap-stack.json')
19
+ import { execSync } from 'node:child_process';
20
+ import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ import { fileURLToPath } from 'node:url';
24
+ import BootstrapConfig from './bootstrap-config.js';
25
+ import AwsProfileParser from './aws-profile-parser.js';
26
+ import AssetManager from './asset-manager.js';
27
+ import { runPrompts } from '../prompt-adapter.js';
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+
32
+ const STACK_NAME_PREFIX = 'mlcc-bootstrap';
33
+ const STACK_TEMPLATE_PATH = path.resolve(__dirname, '../../config/bootstrap-stack.json');
34
34
 
35
35
  export default class BootstrapCommandHandler {
36
36
  constructor({ promptFn } = {}) {
37
- this.config = new BootstrapConfig()
38
- this.profileParser = new AwsProfileParser()
39
- this._promptFn = promptFn || runPrompts
37
+ this.config = new BootstrapConfig();
38
+ this.profileParser = new AwsProfileParser();
39
+ this._promptFn = promptFn || runPrompts;
40
40
  }
41
41
 
42
42
  /**
@@ -46,38 +46,38 @@ export default class BootstrapCommandHandler {
46
46
  */
47
47
  async handle(args, options) {
48
48
  if (args.length === 0) {
49
- await this._handleInteractiveSetup(options)
50
- return
49
+ await this._handleInteractiveSetup(options);
50
+ return;
51
51
  }
52
52
 
53
- const subcommand = args[0].toLowerCase()
53
+ const subcommand = args[0].toLowerCase();
54
54
 
55
55
  switch (subcommand) {
56
- case 'status':
57
- await this._handleStatus(options)
58
- break
59
- case 'use':
60
- await this._handleUse(args[1])
61
- break
62
- case 'list':
63
- await this._handleList()
64
- break
65
- case 'remove':
66
- await this._handleRemove(args[1], options)
67
- break
68
- case 'scan':
69
- await this._handleScan()
70
- break
71
- case 'prune':
72
- await this._handlePrune()
73
- break
74
- case 'update':
75
- await this._handleUpdate(options)
76
- break
77
- default:
78
- console.log(`Unknown bootstrap subcommand: ${subcommand}`)
79
- this._showHelp()
80
- break
56
+ case 'status':
57
+ await this._handleStatus(options);
58
+ break;
59
+ case 'use':
60
+ await this._handleUse(args[1]);
61
+ break;
62
+ case 'list':
63
+ await this._handleList();
64
+ break;
65
+ case 'remove':
66
+ await this._handleRemove(args[1], options);
67
+ break;
68
+ case 'scan':
69
+ await this._handleScan();
70
+ break;
71
+ case 'prune':
72
+ await this._handlePrune();
73
+ break;
74
+ case 'update':
75
+ await this._handleUpdate(options);
76
+ break;
77
+ default:
78
+ console.log(`Unknown bootstrap subcommand: ${subcommand}`);
79
+ this._showHelp();
80
+ break;
81
81
  }
82
82
  }
83
83
 
@@ -86,125 +86,125 @@ export default class BootstrapCommandHandler {
86
86
  * @param {object} options - Parsed CLI options
87
87
  */
88
88
  async _handleInteractiveSetup(options) {
89
- const nonInteractive = options['non-interactive']
89
+ const nonInteractive = options['non-interactive'];
90
90
 
91
91
  // Non-interactive mode: validate required flags upfront
92
92
  if (nonInteractive) {
93
- const missingFlags = []
93
+ const missingFlags = [];
94
94
  if (!options.profile) {
95
- missingFlags.push('--profile')
95
+ missingFlags.push('--profile');
96
96
  }
97
97
  if (!options.region) {
98
- missingFlags.push('--region')
98
+ missingFlags.push('--region');
99
99
  }
100
100
  if (missingFlags.length > 0) {
101
- console.log(`❌ Missing required flags for non-interactive mode: ${missingFlags.join(', ')}`)
102
- return
101
+ console.log(`❌ Missing required flags for non-interactive mode: ${missingFlags.join(', ')}`);
102
+ return;
103
103
  }
104
104
  }
105
105
 
106
- console.log('\n🚀 Bootstrap — Shared AWS Infrastructure Setup\n')
106
+ console.log('\n🚀 Bootstrap — Shared AWS Infrastructure Setup\n');
107
107
 
108
108
  // Determine bootstrap profile name
109
- let profileName
109
+ let profileName;
110
110
  if (nonInteractive) {
111
- profileName = options.name || 'default'
111
+ profileName = options.name || 'default';
112
112
  } else {
113
113
  const answer = await this._promptFn([{
114
114
  type: 'input',
115
115
  name: 'profileName',
116
116
  message: 'Bootstrap profile name:',
117
117
  default: 'default'
118
- }])
119
- profileName = answer.profileName
118
+ }]);
119
+ profileName = answer.profileName;
120
120
  }
121
121
 
122
- const profileData = {}
122
+ const profileData = {};
123
123
 
124
124
  // Step 1: AWS profile selection
125
- this._displayProgress('🔍', 'Selecting AWS profile...')
126
- let awsProfile
125
+ this._displayProgress('🔍', 'Selecting AWS profile...');
126
+ let awsProfile;
127
127
  if (nonInteractive) {
128
- awsProfile = options.profile
128
+ awsProfile = options.profile;
129
129
  } else {
130
- awsProfile = await this._selectProfile(options)
130
+ awsProfile = await this._selectProfile(options);
131
131
  }
132
- profileData.awsProfile = awsProfile
133
- this._currentProfile = awsProfile
132
+ profileData.awsProfile = awsProfile;
133
+ this._currentProfile = awsProfile;
134
134
 
135
135
  // Step 2: Credential validation
136
- this._displayProgress('🔑', 'Validating AWS credentials...')
137
- const { accountId, region } = await this._validateCredentials(awsProfile, nonInteractive ? options.region : undefined)
138
- profileData.accountId = accountId
139
- profileData.awsRegion = region
140
- this._currentRegion = region
141
- this._currentAccountId = accountId
136
+ this._displayProgress('🔑', 'Validating AWS credentials...');
137
+ const { accountId, region } = await this._validateCredentials(awsProfile, nonInteractive ? options.region : undefined);
138
+ profileData.accountId = accountId;
139
+ profileData.awsRegion = region;
140
+ this._currentRegion = region;
141
+ this._currentAccountId = accountId;
142
142
 
143
143
  // Step 3: Determine stack parameters
144
- let useExistingRoleArn = ''
144
+ let useExistingRoleArn = '';
145
145
  if (nonInteractive && options['role-arn']) {
146
- useExistingRoleArn = options['role-arn']
147
- console.log(` Using provided IAM role ARN: ${options['role-arn']}`)
146
+ useExistingRoleArn = options['role-arn'];
147
+ console.log(` Using provided IAM role ARN: ${options['role-arn']}`);
148
148
  }
149
149
 
150
- let createS3Buckets = false
150
+ let createS3Buckets = false;
151
151
  if (nonInteractive && options['skip-s3']) {
152
- console.log(' ⏭️ Skipping S3 bucket creation (--skip-s3)')
152
+ console.log(' ⏭️ Skipping S3 bucket creation (--skip-s3)');
153
153
  } else if (nonInteractive) {
154
- createS3Buckets = true
154
+ createS3Buckets = true;
155
155
  } else {
156
156
  const { useS3 } = await this._promptFn([{
157
157
  type: 'confirm',
158
158
  name: 'useS3',
159
159
  message: 'Will you use async inference or batch transform?',
160
160
  default: false
161
- }])
162
- createS3Buckets = useS3
161
+ }]);
162
+ createS3Buckets = useS3;
163
163
  }
164
164
 
165
165
  // Step 4: Deploy CloudFormation stack
166
- this._displayProgress('☁️', 'Deploying bootstrap infrastructure stack...')
167
- const stackName = `${STACK_NAME_PREFIX}-${profileName}`
166
+ this._displayProgress('☁️', 'Deploying bootstrap infrastructure stack...');
167
+ const stackName = `${STACK_NAME_PREFIX}-${profileName}`;
168
168
 
169
169
  try {
170
170
  const stackOutputs = this._deployStack(stackName, {
171
171
  CreateS3Buckets: createS3Buckets ? 'true' : 'false',
172
172
  UseExistingRoleArn: useExistingRoleArn
173
- }, awsProfile, region)
173
+ }, awsProfile, region);
174
174
 
175
175
  // Read outputs into profile data
176
- profileData.roleArn = stackOutputs.RoleArn
177
- profileData.ecrRepositoryName = stackOutputs.EcrRepositoryName
178
- profileData.stackName = stackName
176
+ profileData.roleArn = stackOutputs.RoleArn;
177
+ profileData.ecrRepositoryName = stackOutputs.EcrRepositoryName;
178
+ profileData.stackName = stackName;
179
179
 
180
180
  if (stackOutputs.AsyncS3BucketName) {
181
- profileData.asyncS3Bucket = stackOutputs.AsyncS3BucketName
181
+ profileData.asyncS3Bucket = stackOutputs.AsyncS3BucketName;
182
182
  }
183
183
  if (stackOutputs.BatchS3BucketName) {
184
- profileData.batchS3Bucket = stackOutputs.BatchS3BucketName
184
+ profileData.batchS3Bucket = stackOutputs.BatchS3BucketName;
185
185
  }
186
186
 
187
- console.log(' ✅ Bootstrap stack deployed successfully')
187
+ console.log(' ✅ Bootstrap stack deployed successfully');
188
188
  } catch (error) {
189
- console.log(` ❌ Stack deployment failed: ${error.message}`)
190
- console.log(' Check the CloudFormation console for details:')
191
- console.log(` https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks`)
192
- return
189
+ console.log(` ❌ Stack deployment failed: ${error.message}`);
190
+ console.log(' Check the CloudFormation console for details:');
191
+ console.log(` https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks`);
192
+ return;
193
193
  }
194
194
 
195
195
  // Step 5: CI Infrastructure setup (separate CDK stack — unchanged)
196
- this._displayProgress('🧪', 'CI Testing Infrastructure...')
196
+ this._displayProgress('🧪', 'CI Testing Infrastructure...');
197
197
  try {
198
- let provisionCi = false
198
+ let provisionCi = false;
199
199
 
200
200
  if (nonInteractive) {
201
201
  if (options.ci) {
202
- provisionCi = true
202
+ provisionCi = true;
203
203
  } else if (options['skip-ci']) {
204
- console.log(' ⏭️ Skipping CI infrastructure (--skip-ci)')
205
- provisionCi = false
204
+ console.log(' ⏭️ Skipping CI infrastructure (--skip-ci)');
205
+ provisionCi = false;
206
206
  } else {
207
- provisionCi = false
207
+ provisionCi = false;
208
208
  }
209
209
  } else {
210
210
  const ciAnswer = await this._promptFn([{
@@ -212,8 +212,8 @@ export default class BootstrapCommandHandler {
212
212
  name: 'useCi',
213
213
  message: 'Do you want CI testing infrastructure?',
214
214
  default: false
215
- }])
216
- provisionCi = ciAnswer.useCi
215
+ }]);
216
+ provisionCi = ciAnswer.useCi;
217
217
  }
218
218
 
219
219
  if (provisionCi) {
@@ -221,10 +221,10 @@ export default class BootstrapCommandHandler {
221
221
  const cdkBootstrapped = this._resourceExists(
222
222
  `ssm get-parameter --name /cdk-bootstrap/hnb659fds/version --region ${profileData.awsRegion}`,
223
223
  profileData.awsProfile
224
- )
224
+ );
225
225
 
226
226
  if (!cdkBootstrapped) {
227
- console.log(' 📦 CDK has not been bootstrapped in this account/region — bootstrapping now...')
227
+ console.log(' 📦 CDK has not been bootstrapped in this account/region — bootstrapping now...');
228
228
  try {
229
229
  execSync(
230
230
  `npx cdk bootstrap aws://${profileData.accountId}/${profileData.awsRegion}`,
@@ -236,12 +236,12 @@ export default class BootstrapCommandHandler {
236
236
  AWS_PROFILE: profileData.awsProfile
237
237
  }
238
238
  }
239
- )
240
- console.log(' ✅ CDK bootstrap complete')
239
+ );
240
+ console.log(' ✅ CDK bootstrap complete');
241
241
  } catch (cdkErr) {
242
- console.log(` ❌ CDK bootstrap failed: ${cdkErr.message}`)
243
- console.log(` Run manually: npx cdk bootstrap aws://${profileData.accountId}/${profileData.awsRegion} --profile ${profileData.awsProfile}`)
244
- throw cdkErr
242
+ console.log(` ❌ CDK bootstrap failed: ${cdkErr.message}`);
243
+ console.log(` Run manually: npx cdk bootstrap aws://${profileData.accountId}/${profileData.awsRegion} --profile ${profileData.awsProfile}`);
244
+ throw cdkErr;
245
245
  }
246
246
  }
247
247
 
@@ -249,52 +249,59 @@ export default class BootstrapCommandHandler {
249
249
  const ciStackExists = this._resourceExists(
250
250
  `cloudformation describe-stacks --stack-name MlccCiHarnessStack --region ${profileData.awsRegion}`,
251
251
  profileData.awsProfile
252
- )
252
+ );
253
253
 
254
254
  if (ciStackExists) {
255
- console.log(' ✅ CI stack already deployed — updating if needed...')
255
+ console.log(' ✅ CI stack already deployed — updating if needed...');
256
256
  } else {
257
- console.log(' 🚀 Deploying CI harness stack...')
257
+ console.log(' 🚀 Deploying CI harness stack...');
258
258
  }
259
259
 
260
- const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness')
260
+ const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness');
261
261
 
262
- // Ensure dependencies are installed (handles cold starts / fresh clones)
263
- execSync('npm install --silent', {
264
- cwd: ciHarnessDir,
265
- encoding: 'utf8',
266
- stdio: ['pipe', 'pipe', 'pipe']
267
- })
268
-
269
- execSync(
270
- `npx cdk deploy MlccCiHarnessStack --require-approval never`,
271
- {
262
+ // CI harness source is not bundled in the npm package — only available from git clone
263
+ if (!existsSync(ciHarnessDir)) {
264
+ console.log(' ⚠️ CI harness source not available (npm install does not include infra/)');
265
+ console.log(' To deploy the CI stack, clone the repo: git clone https://github.com/awslabs/ml-container-creator');
266
+ console.log(' Then run: cd ml-container-creator/infra/ci-harness && npx cdk deploy MlccCiHarnessStack');
267
+ } else {
268
+ // Ensure dependencies are installed (handles cold starts / fresh clones)
269
+ execSync('npm install --silent', {
272
270
  cwd: ciHarnessDir,
273
271
  encoding: 'utf8',
274
- stdio: 'inherit',
275
- env: {
276
- ...process.env,
277
- CDK_DEFAULT_REGION: profileData.awsRegion,
278
- CDK_DEFAULT_ACCOUNT: profileData.accountId,
279
- AWS_PROFILE: profileData.awsProfile
272
+ stdio: ['pipe', 'pipe', 'pipe']
273
+ });
274
+
275
+ execSync(
276
+ 'npx cdk deploy MlccCiHarnessStack --require-approval never',
277
+ {
278
+ cwd: ciHarnessDir,
279
+ encoding: 'utf8',
280
+ stdio: 'inherit',
281
+ env: {
282
+ ...process.env,
283
+ CDK_DEFAULT_REGION: profileData.awsRegion,
284
+ CDK_DEFAULT_ACCOUNT: profileData.accountId,
285
+ AWS_PROFILE: profileData.awsProfile
286
+ }
280
287
  }
281
- }
282
- )
283
- console.log(' ✅ CI harness stack deployed')
288
+ );
289
+ console.log(' ✅ CI harness stack deployed');
284
290
 
285
- profileData.ciInfraProvisioned = true
286
- profileData.ciTableName = 'mlcc-ci-table'
291
+ profileData.ciInfraProvisioned = true;
292
+ profileData.ciTableName = 'mlcc-ci-table';
293
+ }
287
294
  }
288
295
  } catch (error) {
289
- console.log(`⚠️ CI infrastructure setup failed: ${error.message}`)
296
+ console.log(`⚠️ CI infrastructure setup failed: ${error.message}`);
290
297
  }
291
298
 
292
299
  // Save profile to config
293
- this.config.setProfile(profileName, profileData)
294
- this._displayProgress('✅', `Profile "${profileName}" saved to config`)
300
+ this.config.setProfile(profileName, profileData);
301
+ this._displayProgress('✅', `Profile "${profileName}" saved to config`);
295
302
 
296
303
  // Display summary
297
- this._displaySummary(profileName, profileData)
304
+ this._displaySummary(profileName, profileData);
298
305
  }
299
306
 
300
307
  /**
@@ -302,106 +309,106 @@ export default class BootstrapCommandHandler {
302
309
  * @param {object} [options] - Parsed CLI options (e.g., --verify)
303
310
  */
304
311
  async _handleStatus(options = {}) {
305
- const config = this.config.read()
312
+ const config = this.config.read();
306
313
  if (!config) {
307
- console.log('No bootstrap configuration found.')
308
- console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
309
- return
314
+ console.log('No bootstrap configuration found.');
315
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
316
+ return;
310
317
  }
311
318
 
312
- const profile = this.config.getActiveProfile()
319
+ const profile = this.config.getActiveProfile();
313
320
  if (!profile) {
314
- console.log('No active bootstrap profile found.')
315
- console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
316
- return
321
+ console.log('No active bootstrap profile found.');
322
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
323
+ return;
317
324
  }
318
325
 
319
- const allProfiles = this.config.listProfiles()
320
- console.log(`\n📋 Active Profile: ${profile.name} (${allProfiles.length} profile${allProfiles.length === 1 ? '' : 's'} total)`)
321
- console.log('─'.repeat(40))
326
+ const allProfiles = this.config.listProfiles();
327
+ console.log(`\n📋 Active Profile: ${profile.name} (${allProfiles.length} profile${allProfiles.length === 1 ? '' : 's'} total)`);
328
+ console.log('─'.repeat(40));
322
329
 
323
330
  for (const [key, value] of Object.entries(profile.config)) {
324
- console.log(` ${key}: ${value}`)
331
+ console.log(` ${key}: ${value}`);
325
332
  }
326
333
 
327
- console.log('─'.repeat(40))
334
+ console.log('─'.repeat(40));
328
335
 
329
336
  // Validate bootstrap stack
330
- console.log('\n🔍 Resource Validation:')
337
+ console.log('\n🔍 Resource Validation:');
331
338
 
332
- const stackName = profile.config.stackName || `${STACK_NAME_PREFIX}-${profile.name}`
339
+ const stackName = profile.config.stackName || `${STACK_NAME_PREFIX}-${profile.name}`;
333
340
 
334
341
  try {
335
342
  const stackInfo = this._execAws(
336
343
  `cloudformation describe-stacks --stack-name ${stackName} --region ${profile.config.awsRegion}`,
337
344
  profile.config.awsProfile
338
- )
345
+ );
339
346
 
340
- const stack = stackInfo.Stacks && stackInfo.Stacks[0]
347
+ const stack = stackInfo.Stacks && stackInfo.Stacks[0];
341
348
  if (stack) {
342
- const status = stack.StackStatus
343
- const statusIcon = status === 'CREATE_COMPLETE' || status === 'UPDATE_COMPLETE' ? '✅' : '⚠️'
344
- console.log(` ${statusIcon} Bootstrap stack: ${stackName} (${status})`)
349
+ const status = stack.StackStatus;
350
+ const statusIcon = status === 'CREATE_COMPLETE' || status === 'UPDATE_COMPLETE' ? '✅' : '⚠️';
351
+ console.log(` ${statusIcon} Bootstrap stack: ${stackName} (${status})`);
345
352
 
346
353
  // Show stack outputs
347
- const outputs = {}
354
+ const outputs = {};
348
355
  for (const output of (stack.Outputs || [])) {
349
- outputs[output.OutputKey] = output.OutputValue
356
+ outputs[output.OutputKey] = output.OutputValue;
350
357
  }
351
358
 
352
359
  if (outputs.RoleArn) {
353
- console.log(` ✅ IAM role: ${outputs.RoleArn.split('/').pop()}`)
360
+ console.log(` ✅ IAM role: ${outputs.RoleArn.split('/').pop()}`);
354
361
  }
355
362
  if (outputs.EcrRepositoryName) {
356
- console.log(` ✅ ECR repository: ${outputs.EcrRepositoryName}`)
363
+ console.log(` ✅ ECR repository: ${outputs.EcrRepositoryName}`);
357
364
  }
358
365
  if (outputs.AsyncS3BucketName) {
359
- console.log(` ✅ S3 bucket (async): ${outputs.AsyncS3BucketName}`)
366
+ console.log(` ✅ S3 bucket (async): ${outputs.AsyncS3BucketName}`);
360
367
  }
361
368
  if (outputs.BatchS3BucketName) {
362
- console.log(` ✅ S3 bucket (batch): ${outputs.BatchS3BucketName}`)
369
+ console.log(` ✅ S3 bucket (batch): ${outputs.BatchS3BucketName}`);
363
370
  }
364
371
  if (outputs.StackVersion) {
365
- console.log(` 📋 Stack version: ${outputs.StackVersion}`)
372
+ console.log(` 📋 Stack version: ${outputs.StackVersion}`);
366
373
  }
367
374
  }
368
375
  } catch {
369
376
  // Fall back to individual resource checks for profiles created before CloudFormation migration
370
- console.log(` ⚠️ Bootstrap stack "${stackName}" not found — checking resources individually`)
377
+ console.log(` ⚠️ Bootstrap stack "${stackName}" not found — checking resources individually`);
371
378
 
372
379
  try {
373
- const defaultRoleName = 'mlcc-sagemaker-execution-role'
374
- let roleName = defaultRoleName
380
+ const defaultRoleName = 'mlcc-sagemaker-execution-role';
381
+ let roleName = defaultRoleName;
375
382
  if (profile.config.roleArn) {
376
- const arnParts = profile.config.roleArn.split('/')
377
- roleName = arnParts[arnParts.length - 1]
383
+ const arnParts = profile.config.roleArn.split('/');
384
+ roleName = arnParts[arnParts.length - 1];
378
385
  }
379
386
 
380
387
  const roleExists = this._resourceExists(
381
388
  `iam get-role --role-name ${roleName}`,
382
389
  profile.config.awsProfile
383
- )
390
+ );
384
391
  if (roleExists) {
385
- console.log(` ✅ IAM role: ${roleName}`)
392
+ console.log(` ✅ IAM role: ${roleName}`);
386
393
  } else {
387
- console.log(` ⚠️ IAM role: ${roleName} — missing`)
394
+ console.log(` ⚠️ IAM role: ${roleName} — missing`);
388
395
  }
389
396
  } catch {
390
- console.log(' ⚠️ IAM role: could not validate')
397
+ console.log(' ⚠️ IAM role: could not validate');
391
398
  }
392
399
 
393
400
  try {
394
401
  const ecrExists = this._resourceExists(
395
402
  `ecr describe-repositories --repository-names ml-container-creator --region ${profile.config.awsRegion}`,
396
403
  profile.config.awsProfile
397
- )
404
+ );
398
405
  if (ecrExists) {
399
- console.log(' ✅ ECR repository: ml-container-creator')
406
+ console.log(' ✅ ECR repository: ml-container-creator');
400
407
  } else {
401
- console.log(' ⚠️ ECR repository: ml-container-creator — missing')
408
+ console.log(' ⚠️ ECR repository: ml-container-creator — missing');
402
409
  }
403
410
  } catch {
404
- console.log(' ⚠️ ECR repository: could not validate')
411
+ console.log(' ⚠️ ECR repository: could not validate');
405
412
  }
406
413
 
407
414
  if (profile.config.asyncS3Bucket) {
@@ -409,12 +416,12 @@ export default class BootstrapCommandHandler {
409
416
  const asyncExists = this._resourceExists(
410
417
  `s3api head-bucket --bucket ${profile.config.asyncS3Bucket}`,
411
418
  profile.config.awsProfile
412
- )
419
+ );
413
420
  console.log(asyncExists
414
421
  ? ` ✅ S3 bucket: ${profile.config.asyncS3Bucket}`
415
- : ` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — missing`)
422
+ : ` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — missing`);
416
423
  } catch {
417
- console.log(` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — could not validate`)
424
+ console.log(` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — could not validate`);
418
425
  }
419
426
  }
420
427
 
@@ -423,48 +430,48 @@ export default class BootstrapCommandHandler {
423
430
  const batchExists = this._resourceExists(
424
431
  `s3api head-bucket --bucket ${profile.config.batchS3Bucket}`,
425
432
  profile.config.awsProfile
426
- )
433
+ );
427
434
  console.log(batchExists
428
435
  ? ` ✅ S3 bucket: ${profile.config.batchS3Bucket}`
429
- : ` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — missing`)
436
+ : ` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — missing`);
430
437
  } catch {
431
- console.log(` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — could not validate`)
438
+ console.log(` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — could not validate`);
432
439
  }
433
440
  }
434
441
  }
435
442
 
436
443
  // Display deployed resources from manifest
437
- console.log('\n📦 Deployed Resources:')
444
+ console.log('\n📦 Deployed Resources:');
438
445
 
439
- const assetManager = new AssetManager(profile.name)
446
+ const assetManager = new AssetManager(profile.name);
440
447
 
441
448
  if (!existsSync(assetManager.manifestPath)) {
442
- console.log(' No deployment tracking data available.')
443
- console.log(' Resources will be tracked after running deploy, push, or submit scripts.')
444
- return
449
+ console.log(' No deployment tracking data available.');
450
+ console.log(' Resources will be tracked after running deploy, push, or submit scripts.');
451
+ return;
445
452
  }
446
453
 
447
- const resourcesByProject = assetManager.getResourcesByProject()
454
+ const resourcesByProject = assetManager.getResourcesByProject();
448
455
 
449
456
  if (resourcesByProject.size === 0) {
450
- console.log(' No deployed resources tracked.')
451
- return
457
+ console.log(' No deployed resources tracked.');
458
+ return;
452
459
  }
453
460
 
454
461
  for (const [project, resources] of resourcesByProject) {
455
- console.log(`\n Project: ${project}`)
462
+ console.log(`\n Project: ${project}`);
456
463
  for (const resource of resources) {
457
- const timestamp = resource.createdAt || resource.lastUpdatedAt
458
- console.log(` ${resource.resourceType} ${resource.resourceId} [${resource.status}] ${timestamp}`)
464
+ const timestamp = resource.createdAt || resource.lastUpdatedAt;
465
+ console.log(` ${resource.resourceType} ${resource.resourceId} [${resource.status}] ${timestamp}`);
459
466
  }
460
467
  }
461
468
 
462
- const counts = assetManager.getStatusCounts()
463
- console.log(`\n Summary: ${counts.active} active, ${counts.deleted} deleted, ${counts.unknown} unknown`)
469
+ const counts = assetManager.getStatusCounts();
470
+ console.log(`\n Summary: ${counts.active} active, ${counts.deleted} deleted, ${counts.unknown} unknown`);
464
471
 
465
472
  // Drift detection if --verify flag is set
466
473
  if (options.verify) {
467
- await this._handleStatusVerify(profile, assetManager)
474
+ await this._handleStatusVerify(profile, assetManager);
468
475
  }
469
476
  }
470
477
 
@@ -474,45 +481,45 @@ export default class BootstrapCommandHandler {
474
481
  * @param {AssetManager} assetManager - AssetManager instance for the profile
475
482
  */
476
483
  async _handleStatusVerify(profile, assetManager) {
477
- console.log('\n🔎 Drift Detection:')
484
+ console.log('\n🔎 Drift Detection:');
478
485
 
479
- const activeResources = assetManager.listResources({ status: 'active' })
486
+ const activeResources = assetManager.listResources({ status: 'active' });
480
487
 
481
488
  if (activeResources.length === 0) {
482
- console.log(' No active resources to verify.')
483
- return
489
+ console.log(' No active resources to verify.');
490
+ return;
484
491
  }
485
492
 
486
- let verified = 0
487
- let drifted = 0
488
- let unchecked = 0
493
+ let verified = 0;
494
+ let drifted = 0;
495
+ let unchecked = 0;
489
496
 
490
497
  for (const resource of activeResources) {
491
- const checkCommand = this._buildDriftCheckCommand(resource)
498
+ const checkCommand = this._buildDriftCheckCommand(resource);
492
499
 
493
500
  if (!checkCommand) {
494
- unchecked++
495
- continue
501
+ unchecked++;
502
+ continue;
496
503
  }
497
504
 
498
505
  try {
499
- const exists = this._resourceExists(checkCommand, profile.config.awsProfile)
506
+ const exists = this._resourceExists(checkCommand, profile.config.awsProfile);
500
507
 
501
508
  if (exists) {
502
- verified++
503
- console.log(` ✅ ${resource.resourceType}: ${resource.resourceId}`)
509
+ verified++;
510
+ console.log(` ✅ ${resource.resourceType}: ${resource.resourceId}`);
504
511
  } else {
505
- drifted++
506
- assetManager.updateStatus(resource.resourceId, 'unknown')
507
- console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — not found (status updated to unknown)`)
512
+ drifted++;
513
+ assetManager.updateStatus(resource.resourceId, 'unknown');
514
+ console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — not found (status updated to unknown)`);
508
515
  }
509
516
  } catch {
510
- unchecked++
511
- console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — could not verify (credentials or API unavailable)`)
517
+ unchecked++;
518
+ console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — could not verify (credentials or API unavailable)`);
512
519
  }
513
520
  }
514
521
 
515
- console.log(`\n Drift Summary: ${verified} verified, ${drifted} drifted, ${unchecked} unchecked`)
522
+ console.log(`\n Drift Summary: ${verified} verified, ${drifted} drifted, ${unchecked} unchecked`);
516
523
  }
517
524
 
518
525
  /**
@@ -521,38 +528,38 @@ export default class BootstrapCommandHandler {
521
528
  * @returns {string|null} AWS CLI command string, or null if resource type is not supported
522
529
  */
523
530
  _buildDriftCheckCommand(resource) {
524
- const resourceId = resource.resourceId
531
+ const resourceId = resource.resourceId;
525
532
 
526
533
  switch (resource.resourceType) {
527
- case 'sagemaker-endpoint': {
528
- const name = this._extractNameFromArn(resourceId)
529
- return `sagemaker describe-endpoint --endpoint-name ${name}`
530
- }
531
- case 'sagemaker-model': {
532
- const name = this._extractNameFromArn(resourceId)
533
- return `sagemaker describe-model --model-name ${name}`
534
- }
535
- case 'sagemaker-inference-component': {
536
- const name = this._extractNameFromArn(resourceId)
537
- return `sagemaker describe-inference-component --inference-component-name ${name}`
538
- }
539
- case 'ecr-image': {
540
- // resourceId is a full image URI like 111111111111.dkr.ecr.us-east-1.amazonaws.com/repo:tag
541
- const parts = resourceId.split('/')
542
- const repoAndTag = parts[parts.length - 1]
543
- const [repo, tag] = repoAndTag.split(':')
544
- return `ecr describe-images --repository-name ${repo} --image-ids imageTag=${tag || 'latest'}`
545
- }
546
- case 'codebuild-project': {
547
- const name = this._extractNameFromArn(resourceId)
548
- return `codebuild batch-get-projects --names ${name}`
549
- }
550
- case 'iam-role': {
551
- const name = this._extractNameFromArn(resourceId)
552
- return `iam get-role --role-name ${name}`
553
- }
554
- default:
555
- return null
534
+ case 'sagemaker-endpoint': {
535
+ const name = this._extractNameFromArn(resourceId);
536
+ return `sagemaker describe-endpoint --endpoint-name ${name}`;
537
+ }
538
+ case 'sagemaker-model': {
539
+ const name = this._extractNameFromArn(resourceId);
540
+ return `sagemaker describe-model --model-name ${name}`;
541
+ }
542
+ case 'sagemaker-inference-component': {
543
+ const name = this._extractNameFromArn(resourceId);
544
+ return `sagemaker describe-inference-component --inference-component-name ${name}`;
545
+ }
546
+ case 'ecr-image': {
547
+ // resourceId is a full image URI like 111111111111.dkr.ecr.us-east-1.amazonaws.com/repo:tag
548
+ const parts = resourceId.split('/');
549
+ const repoAndTag = parts[parts.length - 1];
550
+ const [repo, tag] = repoAndTag.split(':');
551
+ return `ecr describe-images --repository-name ${repo} --image-ids imageTag=${tag || 'latest'}`;
552
+ }
553
+ case 'codebuild-project': {
554
+ const name = this._extractNameFromArn(resourceId);
555
+ return `codebuild batch-get-projects --names ${name}`;
556
+ }
557
+ case 'iam-role': {
558
+ const name = this._extractNameFromArn(resourceId);
559
+ return `iam get-role --role-name ${name}`;
560
+ }
561
+ default:
562
+ return null;
556
563
  }
557
564
  }
558
565
 
@@ -567,8 +574,8 @@ export default class BootstrapCommandHandler {
567
574
  // arn:aws:sagemaker:us-east-1:111111111111:endpoint/my-endpoint
568
575
  // arn:aws:iam::111111111111:role/my-role
569
576
  // arn:aws:codebuild:us-east-1:111111111111:project/my-project
570
- const parts = arn.split('/')
571
- return parts[parts.length - 1]
577
+ const parts = arn.split('/');
578
+ return parts[parts.length - 1];
572
579
  }
573
580
 
574
581
  /**
@@ -577,54 +584,54 @@ export default class BootstrapCommandHandler {
577
584
  */
578
585
  async _handleUse(profileName) {
579
586
  if (!profileName) {
580
- console.log('Usage: ml-container-creator bootstrap use <profile>')
581
- console.log(' ml-container-creator bootstrap use none (deactivate)')
582
- return
587
+ console.log('Usage: ml-container-creator bootstrap use <profile>');
588
+ console.log(' ml-container-creator bootstrap use none (deactivate)');
589
+ return;
583
590
  }
584
591
 
585
592
  if (profileName === 'none') {
586
- this.config.setActiveProfile(null)
587
- console.log('Active profile cleared. No bootstrap profile is active.')
588
- return
593
+ this.config.setActiveProfile(null);
594
+ console.log('Active profile cleared. No bootstrap profile is active.');
595
+ return;
589
596
  }
590
597
 
591
- const profile = this.config.getProfile(profileName)
598
+ const profile = this.config.getProfile(profileName);
592
599
  if (!profile) {
593
- const available = this.config.listProfiles()
594
- console.log(`Profile "${profileName}" not found.`)
600
+ const available = this.config.listProfiles();
601
+ console.log(`Profile "${profileName}" not found.`);
595
602
  if (available.length > 0) {
596
- console.log(`Available profiles: ${available.join(', ')}`)
603
+ console.log(`Available profiles: ${available.join(', ')}`);
597
604
  } else {
598
- console.log('No profiles configured. Run `ml-container-creator bootstrap` to create one.')
605
+ console.log('No profiles configured. Run `ml-container-creator bootstrap` to create one.');
599
606
  }
600
- return
607
+ return;
601
608
  }
602
609
 
603
- this.config.setActiveProfile(profileName)
604
- console.log(`Switched active profile to "${profileName}".`)
610
+ this.config.setActiveProfile(profileName);
611
+ console.log(`Switched active profile to "${profileName}".`);
605
612
  }
606
613
 
607
614
  /**
608
615
  * List all bootstrap profiles.
609
616
  */
610
617
  async _handleList() {
611
- const profiles = this.config.listProfiles()
618
+ const profiles = this.config.listProfiles();
612
619
 
613
620
  if (profiles.length === 0) {
614
- console.log('No bootstrap profiles configured.')
615
- console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
616
- return
621
+ console.log('No bootstrap profiles configured.');
622
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
623
+ return;
617
624
  }
618
625
 
619
- const config = this.config.read()
620
- const activeProfileName = config ? config.activeProfile : null
626
+ const config = this.config.read();
627
+ const activeProfileName = config ? config.activeProfile : null;
621
628
 
622
- console.log('\nBootstrap Profiles:')
629
+ console.log('\nBootstrap Profiles:');
623
630
  for (const name of profiles) {
624
631
  if (name === activeProfileName) {
625
- console.log(` * ${name} (active)`)
632
+ console.log(` * ${name} (active)`);
626
633
  } else {
627
- console.log(` ${name}`)
634
+ console.log(` ${name}`);
628
635
  }
629
636
  }
630
637
  }
@@ -636,42 +643,42 @@ export default class BootstrapCommandHandler {
636
643
  */
637
644
  async _handleRemove(profileName, options) {
638
645
  if (!profileName) {
639
- console.log('Usage: ml-container-creator bootstrap remove <profile> [--force]')
640
- return
646
+ console.log('Usage: ml-container-creator bootstrap remove <profile> [--force]');
647
+ return;
641
648
  }
642
649
 
643
- const profile = this.config.getProfile(profileName)
650
+ const profile = this.config.getProfile(profileName);
644
651
  if (!profile) {
645
- console.log(`Profile "${profileName}" not found.`)
646
- return
652
+ console.log(`Profile "${profileName}" not found.`);
653
+ return;
647
654
  }
648
655
 
649
656
  // Check for manifest file with active resources
650
- const assetManager = new AssetManager(profileName)
651
- const hasManifest = existsSync(assetManager.manifestPath)
657
+ const assetManager = new AssetManager(profileName);
658
+ const hasManifest = existsSync(assetManager.manifestPath);
652
659
 
653
660
  if (hasManifest) {
654
- const counts = assetManager.getStatusCounts()
661
+ const counts = assetManager.getStatusCounts();
655
662
  if (counts.active > 0 && !options.force) {
656
- console.log(`⚠️ Profile "${profileName}" has ${counts.active} active resource${counts.active === 1 ? '' : 's'} in the deployment manifest.`)
663
+ console.log(`⚠️ Profile "${profileName}" has ${counts.active} active resource${counts.active === 1 ? '' : 's'} in the deployment manifest.`);
657
664
  }
658
665
  }
659
666
 
660
667
  // Check for CloudFormation stack
661
- const stackName = profile.stackName || `${STACK_NAME_PREFIX}-${profileName}`
662
- let hasStack = false
668
+ const stackName = profile.stackName || `${STACK_NAME_PREFIX}-${profileName}`;
669
+ let hasStack = false;
663
670
  try {
664
671
  hasStack = this._resourceExists(
665
672
  `cloudformation describe-stacks --stack-name ${stackName} --region ${profile.awsRegion}`,
666
673
  profile.awsProfile
667
- )
674
+ );
668
675
  } catch {
669
676
  // ignore
670
677
  }
671
678
 
672
679
  if (hasStack && !options.force) {
673
- console.log(`⚠️ Profile "${profileName}" has a CloudFormation stack: ${stackName}`)
674
- console.log(' Use --delete-stack to also delete the AWS resources, or --force to remove the profile only.')
680
+ console.log(`⚠️ Profile "${profileName}" has a CloudFormation stack: ${stackName}`);
681
+ console.log(' Use --delete-stack to also delete the AWS resources, or --force to remove the profile only.');
675
682
  }
676
683
 
677
684
  if (!options.force) {
@@ -680,95 +687,95 @@ export default class BootstrapCommandHandler {
680
687
  name: 'confirm',
681
688
  message: `Remove bootstrap profile "${profileName}"?`,
682
689
  default: false
683
- }])
690
+ }]);
684
691
 
685
692
  if (!confirm) {
686
- console.log('Removal cancelled.')
687
- return
693
+ console.log('Removal cancelled.');
694
+ return;
688
695
  }
689
696
  }
690
697
 
691
698
  // Delete CloudFormation stack if requested
692
699
  if (hasStack && options['delete-stack']) {
693
700
  try {
694
- console.log(`🗑️ Deleting CloudFormation stack: ${stackName}`)
701
+ console.log(`🗑️ Deleting CloudFormation stack: ${stackName}`);
695
702
  execSync(
696
703
  `aws cloudformation delete-stack --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
697
704
  { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
698
- )
699
- console.log(`⏳ Waiting for stack deletion...`)
705
+ );
706
+ console.log('⏳ Waiting for stack deletion...');
700
707
  execSync(
701
708
  `aws cloudformation wait stack-delete-complete --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
702
709
  { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
703
- )
704
- console.log(`✅ Stack "${stackName}" deleted.`)
710
+ );
711
+ console.log(`✅ Stack "${stackName}" deleted.`);
705
712
  } catch (err) {
706
- console.log(`⚠️ Could not delete stack "${stackName}": ${err.message}`)
707
- console.log(' You may need to delete it manually from the CloudFormation console.')
713
+ console.log(`⚠️ Could not delete stack "${stackName}": ${err.message}`);
714
+ console.log(' You may need to delete it manually from the CloudFormation console.');
708
715
  }
709
716
  } else if (hasStack) {
710
- console.log(`Note: CloudFormation stack "${stackName}" was left in place.`)
711
- console.log(' To delete AWS resources, re-run with --delete-stack')
717
+ console.log(`Note: CloudFormation stack "${stackName}" was left in place.`);
718
+ console.log(' To delete AWS resources, re-run with --delete-stack');
712
719
  }
713
720
 
714
721
  // Delete manifest file if it exists
715
722
  if (hasManifest) {
716
723
  try {
717
- unlinkSync(assetManager.manifestPath)
718
- console.log(`Manifest file for "${profileName}" deleted.`)
724
+ unlinkSync(assetManager.manifestPath);
725
+ console.log(`Manifest file for "${profileName}" deleted.`);
719
726
  } catch {
720
- console.log(`⚠️ Could not delete manifest file for "${profileName}".`)
727
+ console.log(`⚠️ Could not delete manifest file for "${profileName}".`);
721
728
  }
722
729
  }
723
730
 
724
- this.config.removeProfile(profileName)
725
- console.log(`Profile "${profileName}" removed.`)
731
+ this.config.removeProfile(profileName);
732
+ console.log(`Profile "${profileName}" removed.`);
726
733
  }
727
734
 
728
735
  /**
729
736
  * Scan AWS for pre-existing MLCC-managed resources and add them to the manifest.
730
737
  */
731
738
  async _handleScan() {
732
- const profile = this.config.getActiveProfile()
739
+ const profile = this.config.getActiveProfile();
733
740
  if (!profile) {
734
- console.log('No active bootstrap profile found.')
735
- console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
736
- return
741
+ console.log('No active bootstrap profile found.');
742
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
743
+ return;
737
744
  }
738
745
 
739
- console.log(`\n🔍 Scanning for pre-existing resources in ${profile.config.awsRegion}...`)
746
+ console.log(`\n🔍 Scanning for pre-existing resources in ${profile.config.awsRegion}...`);
740
747
 
741
- const assetManager = new AssetManager(profile.name)
742
- const now = new Date().toISOString()
743
- let discovered = 0
744
- let added = 0
745
- let skipped = 0
748
+ const assetManager = new AssetManager(profile.name);
749
+ const now = new Date().toISOString();
750
+ let discovered = 0;
751
+ let added = 0;
752
+ let skipped = 0;
746
753
 
747
754
  // 1. Query Resource Groups Tagging API for mlcc:managed-by tagged resources
748
755
  try {
749
- console.log('\n Checking tagged resources...')
756
+ console.log('\n Checking tagged resources...');
750
757
  const tagResult = this._execAws(
751
758
  `resourcegroupstaggingapi get-resources --tag-filters Key=mlcc:managed-by,Values=ml-container-creator --region ${profile.config.awsRegion}`,
752
759
  profile.config.awsProfile
753
- )
760
+ );
754
761
 
755
- const taggedResources = tagResult.ResourceTagMappingList || []
762
+ const taggedResources = tagResult.ResourceTagMappingList || [];
756
763
  for (const tagged of taggedResources) {
757
- discovered++
758
- const arn = tagged.ResourceARN
759
- const existing = assetManager.getResource(arn)
764
+ discovered++;
765
+ const arn = tagged.ResourceARN;
766
+ const existing = assetManager.getResource(arn);
760
767
  if (existing) {
761
- skipped++
762
- continue
768
+ skipped++;
769
+ continue;
763
770
  }
764
771
 
765
- const resourceType = this._inferResourceTypeFromArn(arn)
772
+ const resourceType = this._inferResourceTypeFromArn(arn);
766
773
  if (!resourceType) {
767
- skipped++
768
- continue
774
+ skipped++;
775
+ continue;
769
776
  }
770
777
 
771
- const project = this._inferProjectFromTags(tagged.Tags) || 'unknown'
778
+ const project = this._inferProjectFromTags(tagged.Tags) || 'unknown';
772
779
 
773
780
  try {
774
781
  assetManager.addResource({
@@ -779,34 +786,34 @@ export default class BootstrapCommandHandler {
779
786
  project,
780
787
  status: 'active',
781
788
  metadata: { discoveredBy: 'scan' }
782
- })
783
- added++
789
+ });
790
+ added++;
784
791
  } catch {
785
- skipped++
792
+ skipped++;
786
793
  }
787
794
  }
788
795
  } catch {
789
- console.log(' ⚠️ Could not query tagged resources (credentials or API unavailable)')
796
+ console.log(' ⚠️ Could not query tagged resources (credentials or API unavailable)');
790
797
  }
791
798
 
792
799
  // 2. Query ECR for images in ml-container-creator repository
793
800
  try {
794
- console.log(' Checking ECR images...')
801
+ console.log(' Checking ECR images...');
795
802
  const ecrResult = this._execAws(
796
803
  `ecr describe-images --repository-name ml-container-creator --region ${profile.config.awsRegion}`,
797
804
  profile.config.awsProfile
798
- )
805
+ );
799
806
 
800
- const images = ecrResult.imageDetails || []
807
+ const images = ecrResult.imageDetails || [];
801
808
  for (const image of images) {
802
- const tags = image.imageTags || []
809
+ const tags = image.imageTags || [];
803
810
  for (const tag of tags) {
804
- discovered++
805
- const imageUri = `${profile.config.accountId}.dkr.ecr.${profile.config.awsRegion}.amazonaws.com/ml-container-creator:${tag}`
806
- const existing = assetManager.getResource(imageUri)
811
+ discovered++;
812
+ const imageUri = `${profile.config.accountId}.dkr.ecr.${profile.config.awsRegion}.amazonaws.com/ml-container-creator:${tag}`;
813
+ const existing = assetManager.getResource(imageUri);
807
814
  if (existing) {
808
- skipped++
809
- continue
815
+ skipped++;
816
+ continue;
810
817
  }
811
818
 
812
819
  try {
@@ -823,33 +830,33 @@ export default class BootstrapCommandHandler {
823
830
  region: profile.config.awsRegion,
824
831
  discoveredBy: 'scan'
825
832
  }
826
- })
827
- added++
833
+ });
834
+ added++;
828
835
  } catch {
829
- skipped++
836
+ skipped++;
830
837
  }
831
838
  }
832
839
  }
833
840
  } catch {
834
- console.log(' ⚠️ Could not query ECR images (credentials or API unavailable)')
841
+ console.log(' ⚠️ Could not query ECR images (credentials or API unavailable)');
835
842
  }
836
843
 
837
844
  // 3. Query CodeBuild for *-build-* projects
838
845
  try {
839
- console.log(' Checking CodeBuild projects...')
846
+ console.log(' Checking CodeBuild projects...');
840
847
  const cbResult = this._execAws(
841
848
  `codebuild list-projects --region ${profile.config.awsRegion}`,
842
849
  profile.config.awsProfile
843
- )
850
+ );
844
851
 
845
- const projects = (cbResult.projects || []).filter(name => name.includes('-build-'))
852
+ const projects = (cbResult.projects || []).filter(name => name.includes('-build-'));
846
853
  for (const projectName of projects) {
847
- discovered++
848
- const arn = `arn:aws:codebuild:${profile.config.awsRegion}:${profile.config.accountId}:project/${projectName}`
849
- const existing = assetManager.getResource(arn)
854
+ discovered++;
855
+ const arn = `arn:aws:codebuild:${profile.config.awsRegion}:${profile.config.accountId}:project/${projectName}`;
856
+ const existing = assetManager.getResource(arn);
850
857
  if (existing) {
851
- skipped++
852
- continue
858
+ skipped++;
859
+ continue;
853
860
  }
854
861
 
855
862
  try {
@@ -865,21 +872,21 @@ export default class BootstrapCommandHandler {
865
872
  region: profile.config.awsRegion,
866
873
  discoveredBy: 'scan'
867
874
  }
868
- })
869
- added++
875
+ });
876
+ added++;
870
877
  } catch {
871
- skipped++
878
+ skipped++;
872
879
  }
873
880
  }
874
881
  } catch {
875
- console.log(' ⚠️ Could not query CodeBuild projects (credentials or API unavailable)')
882
+ console.log(' ⚠️ Could not query CodeBuild projects (credentials or API unavailable)');
876
883
  }
877
884
 
878
885
  // Display summary
879
- console.log(`\n Scan complete: ${discovered} discovered, ${added} added, ${skipped} skipped (duplicates or unsupported)`)
886
+ console.log(`\n Scan complete: ${discovered} discovered, ${added} added, ${skipped} skipped (duplicates or unsupported)`);
880
887
 
881
888
  if (discovered === 0) {
882
- console.log(' No MLCC-managed resources were discovered.')
889
+ console.log(' No MLCC-managed resources were discovered.');
883
890
  }
884
891
  }
885
892
 
@@ -888,36 +895,36 @@ export default class BootstrapCommandHandler {
888
895
  * 'deleted' or 'unknown' that are no longer useful.
889
896
  */
890
897
  async _handlePrune() {
891
- const profile = this.config.getActiveProfile()
898
+ const profile = this.config.getActiveProfile();
892
899
  if (!profile) {
893
- console.log('No active bootstrap profile found.')
894
- return
900
+ console.log('No active bootstrap profile found.');
901
+ return;
895
902
  }
896
903
 
897
- const assetManager = new AssetManager(profile.name)
904
+ const assetManager = new AssetManager(profile.name);
898
905
 
899
906
  if (!existsSync(assetManager.manifestPath)) {
900
- console.log('No deployment tracking data to prune.')
901
- return
907
+ console.log('No deployment tracking data to prune.');
908
+ return;
902
909
  }
903
910
 
904
- const before = assetManager.listResources()
905
- const toRemove = before.filter(r => r.status === 'deleted' || r.status === 'unknown')
911
+ const before = assetManager.listResources();
912
+ const toRemove = before.filter(r => r.status === 'deleted' || r.status === 'unknown');
906
913
 
907
914
  if (toRemove.length === 0) {
908
- console.log('Nothing to prune — no deleted or unknown records found.')
909
- return
915
+ console.log('Nothing to prune — no deleted or unknown records found.');
916
+ return;
910
917
  }
911
918
 
912
- console.log(`\n🧹 Pruning ${toRemove.length} stale record${toRemove.length === 1 ? '' : 's'}:\n`)
919
+ console.log(`\n🧹 Pruning ${toRemove.length} stale record${toRemove.length === 1 ? '' : 's'}:\n`);
913
920
 
914
921
  for (const resource of toRemove) {
915
- assetManager.removeResource(resource.resourceId)
916
- console.log(` 🗑️ [${resource.status}] ${resource.resourceType}: ${resource.resourceId}`)
922
+ assetManager.removeResource(resource.resourceId);
923
+ console.log(` 🗑️ [${resource.status}] ${resource.resourceType}: ${resource.resourceId}`);
917
924
  }
918
925
 
919
- const after = assetManager.listResources()
920
- console.log(`\n Done. ${toRemove.length} removed, ${after.length} remaining.`)
926
+ const after = assetManager.listResources();
927
+ console.log(`\n Done. ${toRemove.length} removed, ${after.length} remaining.`);
921
928
  }
922
929
 
923
930
  /**
@@ -928,81 +935,87 @@ export default class BootstrapCommandHandler {
928
935
  * @param {object} [options] - Parsed CLI options (e.g., --ci to force CI update)
929
936
  */
930
937
  async _handleUpdate(options = {}) {
931
- const profile = this.config.getActiveProfile()
938
+ const profile = this.config.getActiveProfile();
932
939
  if (!profile) {
933
- console.log('No active bootstrap profile found.')
934
- console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure first.')
935
- return
940
+ console.log('No active bootstrap profile found.');
941
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure first.');
942
+ return;
936
943
  }
937
944
 
938
- const { name, config: profileConfig } = profile
939
- console.log(`\n🔄 Updating bootstrap infrastructure for profile "${name}"`)
940
- console.log(` Region: ${profileConfig.awsRegion}`)
941
- console.log(` Account: ${profileConfig.accountId}`)
945
+ const { name, config: profileConfig } = profile;
946
+ console.log(`\n🔄 Updating bootstrap infrastructure for profile "${name}"`);
947
+ console.log(` Region: ${profileConfig.awsRegion}`);
948
+ console.log(` Account: ${profileConfig.accountId}`);
942
949
 
943
950
  // Re-deploy the CloudFormation bootstrap stack
944
- const stackName = profileConfig.stackName || `${STACK_NAME_PREFIX}-${name}`
945
- this._displayProgress('☁️', 'Updating bootstrap stack...')
951
+ const stackName = profileConfig.stackName || `${STACK_NAME_PREFIX}-${name}`;
952
+ this._displayProgress('☁️', 'Updating bootstrap stack...');
946
953
 
947
954
  try {
948
955
  const stackOutputs = this._deployStack(stackName, {
949
956
  CreateS3Buckets: (profileConfig.asyncS3Bucket || profileConfig.batchS3Bucket) ? 'true' : 'false',
950
957
  UseExistingRoleArn: ''
951
- }, profileConfig.awsProfile, profileConfig.awsRegion)
958
+ }, profileConfig.awsProfile, profileConfig.awsRegion);
952
959
 
953
960
  // Update profile with any new outputs
954
- if (stackOutputs.RoleArn) profileConfig.roleArn = stackOutputs.RoleArn
955
- if (stackOutputs.EcrRepositoryName) profileConfig.ecrRepositoryName = stackOutputs.EcrRepositoryName
956
- if (stackOutputs.AsyncS3BucketName) profileConfig.asyncS3Bucket = stackOutputs.AsyncS3BucketName
957
- if (stackOutputs.BatchS3BucketName) profileConfig.batchS3Bucket = stackOutputs.BatchS3BucketName
958
- profileConfig.stackName = stackName
961
+ if (stackOutputs.RoleArn) profileConfig.roleArn = stackOutputs.RoleArn;
962
+ if (stackOutputs.EcrRepositoryName) profileConfig.ecrRepositoryName = stackOutputs.EcrRepositoryName;
963
+ if (stackOutputs.AsyncS3BucketName) profileConfig.asyncS3Bucket = stackOutputs.AsyncS3BucketName;
964
+ if (stackOutputs.BatchS3BucketName) profileConfig.batchS3Bucket = stackOutputs.BatchS3BucketName;
965
+ profileConfig.stackName = stackName;
959
966
 
960
- console.log(' ✅ Bootstrap stack updated')
967
+ console.log(' ✅ Bootstrap stack updated');
961
968
  } catch (error) {
962
- console.log(` ❌ Stack update failed: ${error.message}`)
969
+ console.log(` ❌ Stack update failed: ${error.message}`);
963
970
  }
964
971
 
965
972
  // Re-deploy CI stack if it was provisioned or --ci flag is set
966
- const shouldUpdateCi = profileConfig.ciInfraProvisioned || options.ci
973
+ const shouldUpdateCi = profileConfig.ciInfraProvisioned || options.ci;
967
974
  if (shouldUpdateCi) {
968
- this._displayProgress('🧪', 'Updating CI harness stack...')
975
+ this._displayProgress('🧪', 'Updating CI harness stack...');
969
976
 
970
977
  try {
971
- const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness')
978
+ const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness');
972
979
 
973
- // Ensure dependencies are installed (handles cold starts / fresh clones)
974
- execSync('npm install --silent', {
975
- cwd: ciHarnessDir,
976
- encoding: 'utf8',
977
- stdio: ['pipe', 'pipe', 'pipe']
978
- })
979
-
980
- execSync(
981
- `npx cdk deploy MlccCiHarnessStack --require-approval never`,
982
- {
980
+ // CI harness source is not bundled in the npm package — only available from git clone
981
+ if (!existsSync(ciHarnessDir)) {
982
+ console.log(' ⏭️ CI harness source not available (npm install does not include infra/)');
983
+ console.log(' To update the CI stack, run from a git clone: git clone https://github.com/awslabs/ml-container-creator && cd ml-container-creator && npx cdk deploy -c region=REGION');
984
+ } else {
985
+ // Ensure dependencies are installed (handles cold starts / fresh clones)
986
+ execSync('npm install --silent', {
983
987
  cwd: ciHarnessDir,
984
988
  encoding: 'utf8',
985
- stdio: 'inherit',
986
- env: {
987
- ...process.env,
988
- CDK_DEFAULT_REGION: profileConfig.awsRegion,
989
- CDK_DEFAULT_ACCOUNT: profileConfig.accountId,
990
- AWS_PROFILE: profileConfig.awsProfile
989
+ stdio: ['pipe', 'pipe', 'pipe']
990
+ });
991
+
992
+ execSync(
993
+ 'npx cdk deploy MlccCiHarnessStack --require-approval never',
994
+ {
995
+ cwd: ciHarnessDir,
996
+ encoding: 'utf8',
997
+ stdio: 'inherit',
998
+ env: {
999
+ ...process.env,
1000
+ CDK_DEFAULT_REGION: profileConfig.awsRegion,
1001
+ CDK_DEFAULT_ACCOUNT: profileConfig.accountId,
1002
+ AWS_PROFILE: profileConfig.awsProfile
1003
+ }
991
1004
  }
992
- }
993
- )
994
- profileConfig.ciInfraProvisioned = true
995
- console.log(' ✅ CI harness stack updated')
1005
+ );
1006
+ profileConfig.ciInfraProvisioned = true;
1007
+ console.log(' ✅ CI harness stack updated');
1008
+ }
996
1009
  } catch (error) {
997
- console.log(` ❌ CI stack update failed: ${error.message}`)
1010
+ console.log(` ❌ CI stack update failed: ${error.message}`);
998
1011
  }
999
1012
  } else {
1000
- console.log(' ⏭️ CI stack skipped (not provisioned — use --ci to force)')
1013
+ console.log(' ⏭️ CI stack skipped (not provisioned — use --ci to force)');
1001
1014
  }
1002
1015
 
1003
1016
  // Save updated profile
1004
- this.config.setProfile(name, profileConfig)
1005
- console.log(`\n✅ Update complete for profile "${name}"`)
1017
+ this.config.setProfile(name, profileConfig);
1018
+ console.log(`\n✅ Update complete for profile "${name}"`);
1006
1019
  }
1007
1020
 
1008
1021
  /**
@@ -1011,15 +1024,15 @@ export default class BootstrapCommandHandler {
1011
1024
  * @returns {string|null} Resource type or null if not recognized
1012
1025
  */
1013
1026
  _inferResourceTypeFromArn(arn) {
1014
- if (arn.includes(':endpoint/')) return 'sagemaker-endpoint'
1015
- if (arn.includes(':endpoint-config/')) return 'sagemaker-endpoint-config'
1016
- if (arn.includes(':model/')) return 'sagemaker-model'
1017
- if (arn.includes(':inference-component/')) return 'sagemaker-inference-component'
1018
- if (arn.includes(':transform-job/')) return 'sagemaker-transform-job'
1019
- if (arn.includes(':project/')) return 'codebuild-project'
1020
- if (arn.includes(':role/')) return 'iam-role'
1021
- if (arn.includes(':topic')) return 'sns-topic'
1022
- return null
1027
+ if (arn.includes(':endpoint/')) return 'sagemaker-endpoint';
1028
+ if (arn.includes(':endpoint-config/')) return 'sagemaker-endpoint-config';
1029
+ if (arn.includes(':model/')) return 'sagemaker-model';
1030
+ if (arn.includes(':inference-component/')) return 'sagemaker-inference-component';
1031
+ if (arn.includes(':transform-job/')) return 'sagemaker-transform-job';
1032
+ if (arn.includes(':project/')) return 'codebuild-project';
1033
+ if (arn.includes(':role/')) return 'iam-role';
1034
+ if (arn.includes(':topic')) return 'sns-topic';
1035
+ return null;
1023
1036
  }
1024
1037
 
1025
1038
  /**
@@ -1028,9 +1041,9 @@ export default class BootstrapCommandHandler {
1028
1041
  * @returns {string|null} Project name or null
1029
1042
  */
1030
1043
  _inferProjectFromTags(tags) {
1031
- if (!tags) return null
1032
- const projectTag = tags.find(t => t.Key === 'mlcc:project' || t.Key === 'project')
1033
- return projectTag ? projectTag.Value : null
1044
+ if (!tags) return null;
1045
+ const projectTag = tags.find(t => t.Key === 'mlcc:project' || t.Key === 'project');
1046
+ return projectTag ? projectTag.Value : null;
1034
1047
  }
1035
1048
 
1036
1049
  /**
@@ -1041,7 +1054,7 @@ export default class BootstrapCommandHandler {
1041
1054
  _inferProjectFromImageTag(tag) {
1042
1055
  // Tags often follow pattern: project-name-suffix
1043
1056
  // Best effort: use the tag itself as project identifier
1044
- return tag.replace(/-latest$/, '').replace(/-\d+$/, '') || 'unknown'
1057
+ return tag.replace(/-latest$/, '').replace(/-\d+$/, '') || 'unknown';
1045
1058
  }
1046
1059
 
1047
1060
  /**
@@ -1051,8 +1064,8 @@ export default class BootstrapCommandHandler {
1051
1064
  */
1052
1065
  _inferProjectFromCodeBuildName(name) {
1053
1066
  // Pattern: {project}-build-{suffix}
1054
- const match = name.match(/^(.+?)-build-/)
1055
- return match ? match[1] : name
1067
+ const match = name.match(/^(.+?)-build-/);
1068
+ return match ? match[1] : name;
1056
1069
  }
1057
1070
 
1058
1071
  // ── Provisioning steps ──────────────────────────────────────────
@@ -1062,15 +1075,15 @@ export default class BootstrapCommandHandler {
1062
1075
  * @param {object} options - Parsed CLI options
1063
1076
  * @returns {Promise<string>} Selected AWS profile name
1064
1077
  */
1065
- async _selectProfile(options) {
1066
- const profiles = this.profileParser.getProfiles()
1078
+ async _selectProfile(_options) {
1079
+ const profiles = this.profileParser.getProfiles();
1067
1080
 
1068
1081
  if (profiles.length === 0) {
1069
- console.log('❌ No AWS profiles found. Run `aws configure` first.')
1070
- throw new Error('No AWS profiles found. Run `aws configure` first.')
1082
+ console.log('❌ No AWS profiles found. Run `aws configure` first.');
1083
+ throw new Error('No AWS profiles found. Run `aws configure` first.');
1071
1084
  }
1072
1085
 
1073
- const defaultProfile = profiles.includes('default') ? 'default' : profiles[0]
1086
+ const defaultProfile = profiles.includes('default') ? 'default' : profiles[0];
1074
1087
 
1075
1088
  const { awsProfile } = await this._promptFn([{
1076
1089
  type: 'list',
@@ -1078,9 +1091,9 @@ export default class BootstrapCommandHandler {
1078
1091
  message: 'Select an AWS profile:',
1079
1092
  choices: profiles,
1080
1093
  default: defaultProfile
1081
- }])
1094
+ }]);
1082
1095
 
1083
- return awsProfile
1096
+ return awsProfile;
1084
1097
  }
1085
1098
 
1086
1099
  /**
@@ -1090,23 +1103,23 @@ export default class BootstrapCommandHandler {
1090
1103
  * @returns {Promise<object>} Object with accountId and region
1091
1104
  */
1092
1105
  async _validateCredentials(profile, providedRegion) {
1093
- const identity = this._execAws('sts get-caller-identity', profile)
1094
- const accountId = identity.Account
1106
+ const identity = this._execAws('sts get-caller-identity', profile);
1107
+ const accountId = identity.Account;
1095
1108
 
1096
- let region
1109
+ let region;
1097
1110
  if (providedRegion) {
1098
- region = providedRegion
1111
+ region = providedRegion;
1099
1112
  } else {
1100
1113
  const answer = await this._promptFn([{
1101
1114
  type: 'input',
1102
1115
  name: 'region',
1103
1116
  message: 'AWS region for resources:',
1104
1117
  default: 'us-east-1'
1105
- }])
1106
- region = answer.region
1118
+ }]);
1119
+ region = answer.region;
1107
1120
  }
1108
1121
 
1109
- return { accountId, region }
1122
+ return { accountId, region };
1110
1123
  }
1111
1124
 
1112
1125
  /**
@@ -1114,8 +1127,8 @@ export default class BootstrapCommandHandler {
1114
1127
  * @param {object} options - Parsed CLI options
1115
1128
  * @returns {Promise<string>} Role ARN
1116
1129
  */
1117
- async _setupIamRole(options) {
1118
- const roleName = 'mlcc-sagemaker-execution-role'
1130
+ async _setupIamRole(_options) {
1131
+ const roleName = 'mlcc-sagemaker-execution-role';
1119
1132
 
1120
1133
  // Define trust policy for SageMaker
1121
1134
  const trustPolicy = {
@@ -1129,7 +1142,7 @@ export default class BootstrapCommandHandler {
1129
1142
  Action: 'sts:AssumeRole'
1130
1143
  }
1131
1144
  ]
1132
- }
1145
+ };
1133
1146
 
1134
1147
  // Define execution policy with least-privilege permissions
1135
1148
  const executionPolicy = {
@@ -1199,92 +1212,92 @@ export default class BootstrapCommandHandler {
1199
1212
  ]
1200
1213
  }
1201
1214
  ]
1202
- }
1215
+ };
1203
1216
 
1204
1217
  // Check if role already exists
1205
1218
  const roleExists = this._resourceExists(
1206
1219
  `iam get-role --role-name ${roleName}`,
1207
1220
  this._currentProfile
1208
- )
1221
+ );
1209
1222
 
1210
1223
  if (roleExists) {
1211
1224
  const existingRole = this._execAws(
1212
1225
  `iam get-role --role-name ${roleName}`,
1213
1226
  this._currentProfile
1214
- )
1215
- const roleArn = existingRole.Role.Arn
1216
- console.log(` ✅ IAM role "${roleName}" already exists — reused`)
1227
+ );
1228
+ const roleArn = existingRole.Role.Arn;
1229
+ console.log(` ✅ IAM role "${roleName}" already exists — reused`);
1217
1230
 
1218
1231
  // Always update the inline policy and tags to ensure they're current
1219
1232
  try {
1220
- const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy')
1233
+ const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy');
1221
1234
  this._execAws(
1222
1235
  `iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
1223
1236
  this._currentProfile
1224
- )
1225
- console.log(` ✅ IAM policy "mlcc-execution-policy" — updated`)
1237
+ );
1238
+ console.log(' ✅ IAM policy "mlcc-execution-policy" — updated');
1226
1239
  } catch (err) {
1227
- console.log(` ⚠️ Could not update inline policy: ${err.message}`)
1240
+ console.log(` ⚠️ Could not update inline policy: ${err.message}`);
1228
1241
  }
1229
1242
 
1230
1243
  try {
1231
- const tags = this._buildResourceTags()
1244
+ const tags = this._buildResourceTags();
1232
1245
  this._execAws(
1233
1246
  `iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
1234
1247
  this._currentProfile
1235
- )
1236
- console.log(` ✅ IAM role tags — updated`)
1248
+ );
1249
+ console.log(' ✅ IAM role tags — updated');
1237
1250
  } catch (err) {
1238
- console.log(` ⚠️ Could not update role tags: ${err.message}`)
1251
+ console.log(` ⚠️ Could not update role tags: ${err.message}`);
1239
1252
  }
1240
1253
 
1241
- return roleArn
1254
+ return roleArn;
1242
1255
  }
1243
1256
 
1244
1257
  // Display policies to user before creation
1245
- console.log('\n Trust Policy:')
1246
- console.log(JSON.stringify(trustPolicy, null, 2))
1247
- console.log('\n Execution Policy:')
1248
- console.log(JSON.stringify(executionPolicy, null, 2))
1249
- console.log('')
1258
+ console.log('\n Trust Policy:');
1259
+ console.log(JSON.stringify(trustPolicy, null, 2));
1260
+ console.log('\n Execution Policy:');
1261
+ console.log(JSON.stringify(executionPolicy, null, 2));
1262
+ console.log('');
1250
1263
 
1251
1264
  try {
1252
1265
  // Create the IAM role — write policy to temp file to avoid shell escaping issues
1253
- const trustPolicyFile = this._writeJsonTempFile(trustPolicy, 'trust-policy')
1266
+ const trustPolicyFile = this._writeJsonTempFile(trustPolicy, 'trust-policy');
1254
1267
  const createRoleResult = this._execAws(
1255
1268
  `iam create-role --role-name ${roleName} --assume-role-policy-document ${trustPolicyFile}`,
1256
1269
  this._currentProfile
1257
- )
1258
- const roleArn = createRoleResult.Role.Arn
1270
+ );
1271
+ const roleArn = createRoleResult.Role.Arn;
1259
1272
 
1260
1273
  // Attach inline execution policy
1261
- const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy')
1274
+ const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy');
1262
1275
  this._execAws(
1263
1276
  `iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
1264
1277
  this._currentProfile
1265
- )
1278
+ );
1266
1279
 
1267
1280
  // Apply resource tags
1268
- const tags = this._buildResourceTags()
1281
+ const tags = this._buildResourceTags();
1269
1282
  this._execAws(
1270
1283
  `iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
1271
1284
  this._currentProfile
1272
- )
1285
+ );
1273
1286
 
1274
- console.log(` ✅ IAM role "${roleName}" — created`)
1275
- return roleArn
1287
+ console.log(` ✅ IAM role "${roleName}" — created`);
1288
+ return roleArn;
1276
1289
  } catch (error) {
1277
- const errorMessage = error.message || ''
1290
+ const errorMessage = error.message || '';
1278
1291
  if (errorMessage.includes('AccessDenied') || errorMessage.includes('UnauthorizedAccess')) {
1279
- console.log(' ⚠️ Permission denied for iam:CreateRole. Please provide an existing role ARN.')
1292
+ console.log(' ⚠️ Permission denied for iam:CreateRole. Please provide an existing role ARN.');
1280
1293
  const { roleArn } = await this._promptFn([{
1281
1294
  type: 'input',
1282
1295
  name: 'roleArn',
1283
1296
  message: 'Enter an existing IAM role ARN for SageMaker execution:'
1284
- }])
1285
- return roleArn
1297
+ }]);
1298
+ return roleArn;
1286
1299
  }
1287
- throw error
1300
+ throw error;
1288
1301
  }
1289
1302
  }
1290
1303
 
@@ -1293,27 +1306,27 @@ export default class BootstrapCommandHandler {
1293
1306
  * @returns {Promise<string>} ECR repository name
1294
1307
  */
1295
1308
  async _setupEcrRepository() {
1296
- const repoName = 'ml-container-creator'
1309
+ const repoName = 'ml-container-creator';
1297
1310
 
1298
1311
  // Check if repository already exists
1299
1312
  const repoExists = this._resourceExists(
1300
1313
  `ecr describe-repositories --repository-names ${repoName} --region ${this._currentRegion}`,
1301
1314
  this._currentProfile
1302
- )
1315
+ );
1303
1316
 
1304
1317
  if (repoExists) {
1305
- console.log(` ✅ ECR repository "${repoName}" already exists — reused`)
1306
- return repoName
1318
+ console.log(` ✅ ECR repository "${repoName}" already exists — reused`);
1319
+ return repoName;
1307
1320
  }
1308
1321
 
1309
1322
  // Build resource tags
1310
- const tags = this._buildResourceTags()
1323
+ const tags = this._buildResourceTags();
1311
1324
 
1312
1325
  // Create the ECR repository with image scanning and AES256 encryption
1313
1326
  this._execAws(
1314
1327
  `ecr create-repository --repository-name ${repoName} --image-scanning-configuration scanOnPush=true --encryption-configuration encryptionType=AES256 --region ${this._currentRegion} --tags ${this._formatTagsForCli(tags)}`,
1315
1328
  this._currentProfile
1316
- )
1329
+ );
1317
1330
 
1318
1331
  // Apply lifecycle policy to expire untagged images after 30 days
1319
1332
  const lifecyclePolicy = {
@@ -1332,16 +1345,16 @@ export default class BootstrapCommandHandler {
1332
1345
  }
1333
1346
  }
1334
1347
  ]
1335
- }
1348
+ };
1336
1349
 
1337
- const lifecyclePolicyFile = this._writeJsonTempFile(lifecyclePolicy, 'ecr-lifecycle')
1350
+ const lifecyclePolicyFile = this._writeJsonTempFile(lifecyclePolicy, 'ecr-lifecycle');
1338
1351
  this._execAws(
1339
1352
  `ecr put-lifecycle-policy --repository-name ${repoName} --lifecycle-policy-text ${lifecyclePolicyFile} --region ${this._currentRegion}`,
1340
1353
  this._currentProfile
1341
- )
1354
+ );
1342
1355
 
1343
- console.log(` ✅ ECR repository "${repoName}" — created`)
1344
- return repoName
1356
+ console.log(` ✅ ECR repository "${repoName}" — created`);
1357
+ return repoName;
1345
1358
  }
1346
1359
 
1347
1360
  /**
@@ -1354,20 +1367,20 @@ export default class BootstrapCommandHandler {
1354
1367
  name: 'useS3',
1355
1368
  message: 'Will you use async inference or batch transform?',
1356
1369
  default: false
1357
- }])
1370
+ }]);
1358
1371
 
1359
1372
  if (!useS3) {
1360
- return null
1373
+ return null;
1361
1374
  }
1362
1375
 
1363
- const asyncBucketName = `ml-container-creator-async-${this._currentRegion}-${this._currentAccountId}`
1364
- const batchBucketName = `ml-container-creator-batch-${this._currentRegion}-${this._currentAccountId}`
1376
+ const asyncBucketName = `ml-container-creator-async-${this._currentRegion}-${this._currentAccountId}`;
1377
+ const batchBucketName = `ml-container-creator-batch-${this._currentRegion}-${this._currentAccountId}`;
1365
1378
 
1366
- const tags = this._buildResourceTags()
1367
- const asyncS3Bucket = await this._createS3Bucket(asyncBucketName, tags)
1368
- const batchS3Bucket = await this._createS3Bucket(batchBucketName, tags)
1379
+ const tags = this._buildResourceTags();
1380
+ const asyncS3Bucket = await this._createS3Bucket(asyncBucketName, tags);
1381
+ const batchS3Bucket = await this._createS3Bucket(batchBucketName, tags);
1369
1382
 
1370
- return { asyncS3Bucket, batchS3Bucket }
1383
+ return { asyncS3Bucket, batchS3Bucket };
1371
1384
  }
1372
1385
 
1373
1386
  /**
@@ -1381,45 +1394,45 @@ export default class BootstrapCommandHandler {
1381
1394
  const bucketExists = this._resourceExists(
1382
1395
  `s3api head-bucket --bucket ${bucketName}`,
1383
1396
  this._currentProfile
1384
- )
1397
+ );
1385
1398
 
1386
1399
  if (bucketExists) {
1387
- console.log(` ✅ S3 bucket "${bucketName}" already exists — reused`)
1388
- return bucketName
1400
+ console.log(` ✅ S3 bucket "${bucketName}" already exists — reused`);
1401
+ return bucketName;
1389
1402
  }
1390
1403
 
1391
1404
  // Build create-bucket command with region-appropriate configuration
1392
- let createCommand = `s3api create-bucket --bucket ${bucketName} --region ${this._currentRegion}`
1405
+ let createCommand = `s3api create-bucket --bucket ${bucketName} --region ${this._currentRegion}`;
1393
1406
  if (this._currentRegion !== 'us-east-1') {
1394
- createCommand += ` --create-bucket-configuration LocationConstraint=${this._currentRegion}`
1407
+ createCommand += ` --create-bucket-configuration LocationConstraint=${this._currentRegion}`;
1395
1408
  }
1396
1409
 
1397
- this._execAws(createCommand, this._currentProfile)
1410
+ this._execAws(createCommand, this._currentProfile);
1398
1411
 
1399
1412
  // Enable versioning
1400
1413
  this._execAws(
1401
1414
  `s3api put-bucket-versioning --bucket ${bucketName} --versioning-configuration Status=Enabled`,
1402
1415
  this._currentProfile
1403
- )
1416
+ );
1404
1417
 
1405
1418
  // Enable AES256 server-side encryption
1406
- const encryptionConfig = { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }] }
1407
- const encryptionFile = this._writeJsonTempFile(encryptionConfig, 's3-encryption')
1419
+ const encryptionConfig = { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }] };
1420
+ const encryptionFile = this._writeJsonTempFile(encryptionConfig, 's3-encryption');
1408
1421
  this._execAws(
1409
1422
  `s3api put-bucket-encryption --bucket ${bucketName} --server-side-encryption-configuration ${encryptionFile}`,
1410
1423
  this._currentProfile
1411
- )
1424
+ );
1412
1425
 
1413
1426
  // Apply resource tags
1414
- const tagging = { TagSet: tags }
1415
- const taggingFile = this._writeJsonTempFile(tagging, 's3-tagging')
1427
+ const tagging = { TagSet: tags };
1428
+ const taggingFile = this._writeJsonTempFile(tagging, 's3-tagging');
1416
1429
  this._execAws(
1417
1430
  `s3api put-bucket-tagging --bucket ${bucketName} --tagging ${taggingFile}`,
1418
1431
  this._currentProfile
1419
- )
1432
+ );
1420
1433
 
1421
- console.log(` ✅ S3 bucket "${bucketName}" — created`)
1422
- return bucketName
1434
+ console.log(` ✅ S3 bucket "${bucketName}" — created`);
1435
+ return bucketName;
1423
1436
  }
1424
1437
 
1425
1438
  // ── AWS CLI helpers ─────────────────────────────────────────────
@@ -1431,13 +1444,13 @@ export default class BootstrapCommandHandler {
1431
1444
  * @returns {object} Parsed JSON output
1432
1445
  */
1433
1446
  _execAws(command, profile) {
1434
- const fullCommand = `aws ${command} --profile ${profile} --output json`
1435
- const output = execSync(fullCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
1436
- const trimmed = output.trim()
1447
+ const fullCommand = `aws ${command} --profile ${profile} --output json`;
1448
+ const output = execSync(fullCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1449
+ const trimmed = output.trim();
1437
1450
  if (!trimmed) {
1438
- return {}
1451
+ return {};
1439
1452
  }
1440
- return JSON.parse(trimmed)
1453
+ return JSON.parse(trimmed);
1441
1454
  }
1442
1455
 
1443
1456
  /**
@@ -1458,7 +1471,7 @@ export default class BootstrapCommandHandler {
1458
1471
  // Build parameter overrides string
1459
1472
  const paramOverrides = Object.entries(parameters)
1460
1473
  .map(([key, value]) => `${key}=${value}`)
1461
- .join(' ')
1474
+ .join(' ');
1462
1475
 
1463
1476
  const deployCommand = [
1464
1477
  'aws cloudformation deploy',
@@ -1468,18 +1481,18 @@ export default class BootstrapCommandHandler {
1468
1481
  `--parameter-overrides ${paramOverrides}`,
1469
1482
  `--profile ${profile}`,
1470
1483
  `--region ${region}`
1471
- ].join(' ')
1484
+ ].join(' ');
1472
1485
 
1473
1486
  try {
1474
- execSync(deployCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
1487
+ execSync(deployCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1475
1488
  } catch (error) {
1476
1489
  // "No changes to deploy" is a success case — CloudFormation deploy
1477
1490
  // exits with code 255 when there's nothing to update
1478
- const stderr = error.stderr || error.message || ''
1491
+ const stderr = error.stderr || error.message || '';
1479
1492
  if (stderr.includes('No changes to deploy')) {
1480
- console.log(' ℹ️ Stack is up to date — no changes needed')
1493
+ console.log(' ℹ️ Stack is up to date — no changes needed');
1481
1494
  } else {
1482
- throw error
1495
+ throw error;
1483
1496
  }
1484
1497
  }
1485
1498
 
@@ -1487,19 +1500,19 @@ export default class BootstrapCommandHandler {
1487
1500
  const describeResult = this._execAws(
1488
1501
  `cloudformation describe-stacks --stack-name ${stackName} --region ${region}`,
1489
1502
  profile
1490
- )
1503
+ );
1491
1504
 
1492
- const stack = describeResult.Stacks && describeResult.Stacks[0]
1505
+ const stack = describeResult.Stacks && describeResult.Stacks[0];
1493
1506
  if (!stack) {
1494
- throw new Error(`Stack "${stackName}" not found after deployment`)
1507
+ throw new Error(`Stack "${stackName}" not found after deployment`);
1495
1508
  }
1496
1509
 
1497
- const outputs = {}
1510
+ const outputs = {};
1498
1511
  for (const output of (stack.Outputs || [])) {
1499
- outputs[output.OutputKey] = output.OutputValue
1512
+ outputs[output.OutputKey] = output.OutputValue;
1500
1513
  }
1501
1514
 
1502
- return outputs
1515
+ return outputs;
1503
1516
  }
1504
1517
 
1505
1518
  /**
@@ -1511,13 +1524,13 @@ export default class BootstrapCommandHandler {
1511
1524
  * @returns {string} The `file://` path to the temp file
1512
1525
  */
1513
1526
  _writeJsonTempFile(jsonObj, prefix = 'mlcc-policy') {
1514
- const dir = path.join(tmpdir(), 'mlcc-bootstrap')
1527
+ const dir = path.join(tmpdir(), 'mlcc-bootstrap');
1515
1528
  if (!existsSync(dir)) {
1516
- mkdirSync(dir, { recursive: true })
1529
+ mkdirSync(dir, { recursive: true });
1517
1530
  }
1518
- const filePath = path.join(dir, `${prefix}-${Date.now()}.json`)
1519
- writeFileSync(filePath, JSON.stringify(jsonObj))
1520
- return `file://${filePath}`
1531
+ const filePath = path.join(dir, `${prefix}-${Date.now()}.json`);
1532
+ writeFileSync(filePath, JSON.stringify(jsonObj));
1533
+ return `file://${filePath}`;
1521
1534
  }
1522
1535
 
1523
1536
  /**
@@ -1528,10 +1541,10 @@ export default class BootstrapCommandHandler {
1528
1541
  */
1529
1542
  _resourceExists(checkCommand, profile) {
1530
1543
  try {
1531
- this._execAws(checkCommand, profile)
1532
- return true
1544
+ this._execAws(checkCommand, profile);
1545
+ return true;
1533
1546
  } catch {
1534
- return false
1547
+ return false;
1535
1548
  }
1536
1549
  }
1537
1550
 
@@ -1542,13 +1555,13 @@ export default class BootstrapCommandHandler {
1542
1555
  * @returns {Array<{Key: string, Value: string}>} Tag array
1543
1556
  */
1544
1557
  _buildResourceTags() {
1545
- const packageJsonPath = path.resolve(__dirname, '../../package.json')
1546
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
1558
+ const packageJsonPath = path.resolve(__dirname, '../../package.json');
1559
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
1547
1560
  return [
1548
1561
  { Key: 'mlcc:managed-by', Value: 'ml-container-creator' },
1549
1562
  { Key: 'mlcc:created-by', Value: 'bootstrap' },
1550
1563
  { Key: 'mlcc:version', Value: packageJson.version }
1551
- ]
1564
+ ];
1552
1565
  }
1553
1566
 
1554
1567
  /**
@@ -1560,7 +1573,7 @@ export default class BootstrapCommandHandler {
1560
1573
  * @returns {string} file:// path to the tags JSON file
1561
1574
  */
1562
1575
  _formatTagsForCli(tags) {
1563
- return this._writeJsonTempFile(tags, 'tags')
1576
+ return this._writeJsonTempFile(tags, 'tags');
1564
1577
  }
1565
1578
 
1566
1579
  // ── Display helpers ─────────────────────────────────────────────
@@ -1619,7 +1632,7 @@ EXAMPLES:
1619
1632
  ml-container-creator bootstrap --non-interactive --profile my-aws-profile --role-arn arn:aws:iam::123456789012:role/MyRole --skip-s3
1620
1633
  ml-container-creator bootstrap --non-interactive --profile my-aws-profile --region us-west-2 --ci
1621
1634
  ml-container-creator bootstrap --non-interactive --profile my-aws-profile --region us-west-2 --skip-ci
1622
- `)
1635
+ `);
1623
1636
  }
1624
1637
 
1625
1638
  /**
@@ -1628,12 +1641,12 @@ EXAMPLES:
1628
1641
  * @param {object} profileConfig - Profile configuration object
1629
1642
  */
1630
1643
  _displaySummary(profileName, profileConfig) {
1631
- console.log(`\n📋 Bootstrap Profile: ${profileName}`)
1632
- console.log('─'.repeat(40))
1644
+ console.log(`\n📋 Bootstrap Profile: ${profileName}`);
1645
+ console.log('─'.repeat(40));
1633
1646
  for (const [key, value] of Object.entries(profileConfig)) {
1634
- console.log(` ${key}: ${value}`)
1647
+ console.log(` ${key}: ${value}`);
1635
1648
  }
1636
- console.log('─'.repeat(40))
1649
+ console.log('─'.repeat(40));
1637
1650
  }
1638
1651
 
1639
1652
  /**
@@ -1642,6 +1655,6 @@ EXAMPLES:
1642
1655
  * @param {string} message - Progress message
1643
1656
  */
1644
1657
  _displayProgress(emoji, message) {
1645
- console.log(`${emoji} ${message}`)
1658
+ console.log(`${emoji} ${message}`);
1646
1659
  }
1647
1660
  }