@devramps/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2563 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { program } from "commander";
5
+
6
+ // src/commands/bootstrap.ts
7
+ import ora from "ora";
8
+
9
+ // src/aws/credentials.ts
10
+ import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
11
+
12
+ // src/utils/errors.ts
13
+ var DevRampsError = class extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "DevRampsError";
17
+ }
18
+ };
19
+ var NoDevrampsFolderError = class extends DevRampsError {
20
+ constructor() {
21
+ super(
22
+ "Could not find .devramps folder in current directory. Please run this command from the root of your project."
23
+ );
24
+ this.name = "NoDevrampsFolderError";
25
+ }
26
+ };
27
+ var NoCredentialsError = class extends DevRampsError {
28
+ constructor() {
29
+ super(
30
+ "No AWS credentials found. Please configure AWS credentials using `aws configure` or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables."
31
+ );
32
+ this.name = "NoCredentialsError";
33
+ }
34
+ };
35
+ var RoleAssumptionError = class extends DevRampsError {
36
+ targetAccountId;
37
+ sourceAccountId;
38
+ roleName;
39
+ constructor(targetAccountId, roleName, sourceAccountId) {
40
+ super(
41
+ `Cannot bootstrap account ${targetAccountId}. Your current credentials (account ${sourceAccountId}) cannot assume role '${roleName}' in the target account. Please ensure the target account has a trust policy allowing your account to assume this role, or use --target-account-role-name to specify a different role.`
42
+ );
43
+ this.name = "RoleAssumptionError";
44
+ this.targetAccountId = targetAccountId;
45
+ this.sourceAccountId = sourceAccountId;
46
+ this.roleName = roleName;
47
+ }
48
+ };
49
+ var PipelineParseError = class extends DevRampsError {
50
+ pipelineSlug;
51
+ constructor(pipelineSlug, cause) {
52
+ super(`Failed to parse pipeline.yaml in .devramps/${pipelineSlug}/: ${cause}`);
53
+ this.name = "PipelineParseError";
54
+ this.pipelineSlug = pipelineSlug;
55
+ }
56
+ };
57
+ var AuthenticationError = class extends DevRampsError {
58
+ constructor(message) {
59
+ super(`Authentication failed: ${message}`);
60
+ this.name = "AuthenticationError";
61
+ }
62
+ };
63
+ var CloudFormationError = class extends DevRampsError {
64
+ stackName;
65
+ accountId;
66
+ constructor(stackName, accountId, cause) {
67
+ super(`Failed to deploy stack '${stackName}' in account ${accountId}: ${cause}`);
68
+ this.name = "CloudFormationError";
69
+ this.stackName = stackName;
70
+ this.accountId = accountId;
71
+ }
72
+ };
73
+
74
+ // src/utils/logger.ts
75
+ import chalk from "chalk";
76
+ var verboseMode = false;
77
+ function setVerbose(enabled) {
78
+ verboseMode = enabled;
79
+ }
80
+ function isVerbose() {
81
+ return verboseMode;
82
+ }
83
+ function info(message) {
84
+ console.log(chalk.blue("\u2139"), message);
85
+ }
86
+ function success(message) {
87
+ console.log(chalk.green("\u2714"), message);
88
+ }
89
+ function warn(message) {
90
+ console.log(chalk.yellow("\u26A0"), message);
91
+ }
92
+ function error(message) {
93
+ console.error(chalk.red("\u2716"), message);
94
+ }
95
+ function verbose(message) {
96
+ if (verboseMode) {
97
+ console.log(chalk.gray(" \u2192"), chalk.gray(message));
98
+ }
99
+ }
100
+ function header(message) {
101
+ console.log();
102
+ console.log(chalk.bold.underline(message));
103
+ console.log();
104
+ }
105
+ function table(rows) {
106
+ if (rows.length === 0) return;
107
+ const colWidths = rows[0].map(
108
+ (_, colIndex) => Math.max(...rows.map((row) => (row[colIndex] || "").length))
109
+ );
110
+ const separator = "\u2500";
111
+ const corner = "\u253C";
112
+ const vertical = "\u2502";
113
+ const formatRow = (row, isHeader = false) => {
114
+ const cells = row.map((cell, i) => ` ${cell.padEnd(colWidths[i])} `);
115
+ const line = vertical + cells.join(vertical) + vertical;
116
+ return isHeader ? chalk.bold(line) : line;
117
+ };
118
+ const horizontalLine = (char) => {
119
+ const segments = colWidths.map((w) => char.repeat(w + 2));
120
+ return char === "\u2500" ? "\u251C" + segments.join(corner) + "\u2524" : "\u250C" + segments.join("\u252C") + "\u2510";
121
+ };
122
+ const bottomLine = () => {
123
+ const segments = colWidths.map((w) => separator.repeat(w + 2));
124
+ return "\u2514" + segments.join("\u2534") + "\u2518";
125
+ };
126
+ console.log(horizontalLine("\u2500").replace("\u251C", "\u250C").replace("\u2524", "\u2510").replace(/┼/g, "\u252C"));
127
+ console.log(formatRow(rows[0], true));
128
+ console.log(horizontalLine("\u2500"));
129
+ for (let i = 1; i < rows.length; i++) {
130
+ console.log(formatRow(rows[i]));
131
+ }
132
+ console.log(bottomLine());
133
+ }
134
+ function newline() {
135
+ console.log();
136
+ }
137
+
138
+ // src/aws/credentials.ts
139
+ var DEFAULT_REGION = "us-east-1";
140
+ async function getCurrentIdentity() {
141
+ const client = new STSClient({ region: DEFAULT_REGION });
142
+ try {
143
+ verbose("Checking AWS credentials...");
144
+ const response = await client.send(new GetCallerIdentityCommand({}));
145
+ if (!response.Account || !response.Arn || !response.UserId) {
146
+ throw new NoCredentialsError();
147
+ }
148
+ verbose(`Authenticated as: ${response.Arn}`);
149
+ verbose(`Account ID: ${response.Account}`);
150
+ return {
151
+ accountId: response.Account,
152
+ arn: response.Arn,
153
+ userId: response.UserId
154
+ };
155
+ } catch (error2) {
156
+ if (error2 instanceof NoCredentialsError) {
157
+ throw error2;
158
+ }
159
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
160
+ if (errorMessage.includes("Could not load credentials") || errorMessage.includes("Missing credentials") || errorMessage.includes("ExpiredToken") || errorMessage.includes("InvalidClientTokenId")) {
161
+ throw new NoCredentialsError();
162
+ }
163
+ throw error2;
164
+ }
165
+ }
166
+
167
+ // src/aws/assume-role.ts
168
+ import { STSClient as STSClient2, AssumeRoleCommand } from "@aws-sdk/client-sts";
169
+
170
+ // src/types/config.ts
171
+ var DEFAULT_TARGET_ROLE = "OrganizationAccountAccessRole";
172
+ var FALLBACK_TARGET_ROLE = "AWSControlTowerExecution";
173
+ var OIDC_PROVIDER_URL = "devramps.com";
174
+
175
+ // src/aws/assume-role.ts
176
+ async function assumeRoleForAccount(options) {
177
+ const { targetAccountId, currentAccountId, targetRoleName } = options;
178
+ if (targetAccountId === currentAccountId) {
179
+ verbose(`Target account ${targetAccountId} is the current account, using current credentials`);
180
+ return null;
181
+ }
182
+ const rolesToTry = targetRoleName ? [targetRoleName] : [DEFAULT_TARGET_ROLE, FALLBACK_TARGET_ROLE];
183
+ let lastError;
184
+ for (const roleName of rolesToTry) {
185
+ const roleArn = `arn:aws:iam::${targetAccountId}:role/${roleName}`;
186
+ try {
187
+ verbose(`Attempting to assume role: ${roleArn}`);
188
+ const credentials = await assumeRole(roleArn);
189
+ verbose(`Successfully assumed role: ${roleName}`);
190
+ return {
191
+ credentials,
192
+ accountId: targetAccountId,
193
+ roleArn
194
+ };
195
+ } catch (error2) {
196
+ verbose(`Failed to assume role ${roleName}: ${error2 instanceof Error ? error2.message : String(error2)}`);
197
+ lastError = error2 instanceof Error ? error2 : new Error(String(error2));
198
+ if (targetRoleName) {
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ const attemptedRole = targetRoleName || `${DEFAULT_TARGET_ROLE} or ${FALLBACK_TARGET_ROLE}`;
204
+ throw new RoleAssumptionError(targetAccountId, attemptedRole, currentAccountId);
205
+ }
206
+ var DEFAULT_REGION2 = "us-east-1";
207
+ async function assumeRole(roleArn) {
208
+ const client = new STSClient2({ region: DEFAULT_REGION2 });
209
+ const response = await client.send(
210
+ new AssumeRoleCommand({
211
+ RoleArn: roleArn,
212
+ RoleSessionName: "DevRampsBootstrap",
213
+ DurationSeconds: 3600
214
+ // 1 hour
215
+ })
216
+ );
217
+ if (!response.Credentials) {
218
+ throw new Error("AssumeRole returned no credentials");
219
+ }
220
+ const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
221
+ if (!AccessKeyId || !SecretAccessKey) {
222
+ throw new Error("AssumeRole returned incomplete credentials");
223
+ }
224
+ return {
225
+ accessKeyId: AccessKeyId,
226
+ secretAccessKey: SecretAccessKey,
227
+ sessionToken: SessionToken,
228
+ expiration: Expiration
229
+ };
230
+ }
231
+
232
+ // src/aws/cloudformation.ts
233
+ import {
234
+ CloudFormationClient,
235
+ DescribeStacksCommand,
236
+ DescribeStackResourcesCommand,
237
+ CreateStackCommand,
238
+ UpdateStackCommand,
239
+ CreateChangeSetCommand,
240
+ DescribeChangeSetCommand,
241
+ DeleteChangeSetCommand,
242
+ waitUntilStackCreateComplete,
243
+ waitUntilStackUpdateComplete,
244
+ waitUntilChangeSetCreateComplete,
245
+ ChangeSetType
246
+ } from "@aws-sdk/client-cloudformation";
247
+ async function getStackStatus(stackName, credentials, region) {
248
+ const client = new CloudFormationClient({
249
+ credentials,
250
+ region
251
+ });
252
+ try {
253
+ const response = await client.send(
254
+ new DescribeStacksCommand({ StackName: stackName })
255
+ );
256
+ const stack = response.Stacks?.[0];
257
+ if (!stack) {
258
+ return { exists: false };
259
+ }
260
+ return {
261
+ exists: true,
262
+ status: stack.StackStatus,
263
+ stackId: stack.StackId
264
+ };
265
+ } catch (error2) {
266
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
267
+ if (errorMessage.includes("does not exist")) {
268
+ return { exists: false };
269
+ }
270
+ throw error2;
271
+ }
272
+ }
273
+ async function previewStackChanges(options) {
274
+ const { stackName, template, region, credentials } = options;
275
+ const client = new CloudFormationClient({
276
+ credentials,
277
+ region
278
+ });
279
+ const templateBody = JSON.stringify(template);
280
+ const stackStatus = await getStackStatus(stackName, credentials, region);
281
+ const changeSetName = `devramps-preview-${Date.now()}`;
282
+ try {
283
+ await client.send(
284
+ new CreateChangeSetCommand({
285
+ StackName: stackName,
286
+ ChangeSetName: changeSetName,
287
+ TemplateBody: templateBody,
288
+ Capabilities: ["CAPABILITY_NAMED_IAM"],
289
+ ChangeSetType: stackStatus.exists ? ChangeSetType.UPDATE : ChangeSetType.CREATE
290
+ })
291
+ );
292
+ await waitUntilChangeSetCreateComplete(
293
+ { client, maxWaitTime: 120 },
294
+ { StackName: stackName, ChangeSetName: changeSetName }
295
+ );
296
+ const changeSetResponse = await client.send(
297
+ new DescribeChangeSetCommand({
298
+ StackName: stackName,
299
+ ChangeSetName: changeSetName
300
+ })
301
+ );
302
+ logStackChanges(stackName, changeSetResponse.Changes || [], stackStatus.exists);
303
+ await client.send(
304
+ new DeleteChangeSetCommand({
305
+ StackName: stackName,
306
+ ChangeSetName: changeSetName
307
+ })
308
+ );
309
+ } catch (error2) {
310
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
311
+ if (errorMessage.includes("No updates are to be performed") || errorMessage.includes("didn't contain changes")) {
312
+ verbose(` Stack ${stackName}: No changes`);
313
+ return;
314
+ }
315
+ try {
316
+ await client.send(
317
+ new DeleteChangeSetCommand({
318
+ StackName: stackName,
319
+ ChangeSetName: changeSetName
320
+ })
321
+ );
322
+ } catch {
323
+ }
324
+ verbose(` Could not preview changes for ${stackName}: ${errorMessage}`);
325
+ }
326
+ }
327
+ function logStackChanges(stackName, changes, isUpdate) {
328
+ if (changes.length === 0) {
329
+ verbose(` Stack ${stackName}: No changes`);
330
+ return;
331
+ }
332
+ const action = isUpdate ? "update" : "create";
333
+ info(` Stack ${stackName} will ${action} ${changes.length} resource(s):`);
334
+ for (const change of changes) {
335
+ const resourceChange = change.ResourceChange;
336
+ if (!resourceChange) continue;
337
+ const actionSymbol = getActionSymbol(resourceChange.Action);
338
+ const resourceType = resourceChange.ResourceType || "Unknown";
339
+ const logicalId = resourceChange.LogicalResourceId || "Unknown";
340
+ const replacement = resourceChange.Replacement === "True" ? " (REPLACEMENT)" : "";
341
+ info(` ${actionSymbol} ${resourceType} ${logicalId}${replacement}`);
342
+ }
343
+ }
344
+ function getActionSymbol(action) {
345
+ switch (action) {
346
+ case "Add":
347
+ return "+";
348
+ case "Modify":
349
+ return "~";
350
+ case "Remove":
351
+ return "-";
352
+ case "Import":
353
+ return ">";
354
+ case "Dynamic":
355
+ return "?";
356
+ default:
357
+ return " ";
358
+ }
359
+ }
360
+ async function deployStack(options) {
361
+ const { stackName, template, accountId, region, credentials } = options;
362
+ const client = new CloudFormationClient({
363
+ credentials,
364
+ region
365
+ });
366
+ const templateBody = JSON.stringify(template);
367
+ try {
368
+ const stackStatus = await getStackStatus(stackName, credentials, region);
369
+ if (stackStatus.exists) {
370
+ verbose(`Stack ${stackName} exists, updating...`);
371
+ await updateStack(client, stackName, templateBody, accountId);
372
+ } else {
373
+ verbose(`Stack ${stackName} does not exist, creating...`);
374
+ await createStack(client, stackName, templateBody, accountId);
375
+ }
376
+ } catch (error2) {
377
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
378
+ if (errorMessage.includes("No updates are to be performed")) {
379
+ verbose(`Stack ${stackName} is already up to date`);
380
+ return;
381
+ }
382
+ throw new CloudFormationError(stackName, accountId, errorMessage);
383
+ }
384
+ }
385
+ async function createStack(client, stackName, templateBody, accountId) {
386
+ await client.send(
387
+ new CreateStackCommand({
388
+ StackName: stackName,
389
+ TemplateBody: templateBody,
390
+ Capabilities: ["CAPABILITY_NAMED_IAM"],
391
+ Tags: [
392
+ { Key: "CreatedBy", Value: "DevRamps" },
393
+ { Key: "ManagedBy", Value: "DevRamps-CLI" }
394
+ ]
395
+ })
396
+ );
397
+ verbose(`Waiting for stack ${stackName} to be created...`);
398
+ await waitUntilStackCreateComplete(
399
+ { client, maxWaitTime: 600 },
400
+ { StackName: stackName }
401
+ );
402
+ success(`Stack ${stackName} created successfully in account ${accountId}`);
403
+ }
404
+ async function updateStack(client, stackName, templateBody, accountId) {
405
+ await client.send(
406
+ new UpdateStackCommand({
407
+ StackName: stackName,
408
+ TemplateBody: templateBody,
409
+ Capabilities: ["CAPABILITY_NAMED_IAM"]
410
+ })
411
+ );
412
+ verbose(`Waiting for stack ${stackName} to be updated...`);
413
+ await waitUntilStackUpdateComplete(
414
+ { client, maxWaitTime: 600 },
415
+ { StackName: stackName }
416
+ );
417
+ success(`Stack ${stackName} updated successfully in account ${accountId}`);
418
+ }
419
+ async function readExistingStack(stackName, accountId, region, credentials) {
420
+ const client = new CloudFormationClient({
421
+ credentials,
422
+ region
423
+ });
424
+ try {
425
+ const stacksResponse = await client.send(
426
+ new DescribeStacksCommand({ StackName: stackName })
427
+ );
428
+ const stack = stacksResponse.Stacks?.[0];
429
+ if (!stack) {
430
+ return null;
431
+ }
432
+ const resourcesResponse = await client.send(
433
+ new DescribeStackResourcesCommand({ StackName: stackName })
434
+ );
435
+ const outputs = {};
436
+ if (stack.Outputs) {
437
+ for (const output of stack.Outputs) {
438
+ if (output.OutputKey && output.OutputValue) {
439
+ outputs[output.OutputKey] = output.OutputValue;
440
+ }
441
+ }
442
+ }
443
+ const resources = {};
444
+ if (resourcesResponse.StackResources) {
445
+ for (const resource of resourcesResponse.StackResources) {
446
+ if (resource.LogicalResourceId) {
447
+ resources[resource.LogicalResourceId] = {
448
+ type: resource.ResourceType,
449
+ physicalId: resource.PhysicalResourceId,
450
+ status: resource.ResourceStatus
451
+ };
452
+ }
453
+ }
454
+ }
455
+ return {
456
+ stackName,
457
+ accountId,
458
+ region,
459
+ resources,
460
+ outputs
461
+ };
462
+ } catch (error2) {
463
+ const errorMessage = error2 instanceof Error ? error2.message : String(error2);
464
+ if (errorMessage.includes("does not exist")) {
465
+ return null;
466
+ }
467
+ verbose(`Could not read stack ${stackName}: ${errorMessage}`);
468
+ return null;
469
+ }
470
+ }
471
+
472
+ // src/aws/oidc-provider.ts
473
+ import {
474
+ IAMClient,
475
+ GetOpenIDConnectProviderCommand,
476
+ ListOpenIDConnectProvidersCommand
477
+ } from "@aws-sdk/client-iam";
478
+ async function checkOidcProviderExists(credentials, region) {
479
+ const client = new IAMClient({
480
+ credentials,
481
+ region
482
+ });
483
+ try {
484
+ const response = await client.send(new ListOpenIDConnectProvidersCommand({}));
485
+ const providers = response.OpenIDConnectProviderList || [];
486
+ for (const provider of providers) {
487
+ if (!provider.Arn) continue;
488
+ try {
489
+ const providerDetails = await client.send(
490
+ new GetOpenIDConnectProviderCommand({
491
+ OpenIDConnectProviderArn: provider.Arn
492
+ })
493
+ );
494
+ if (providerDetails.Url?.includes(OIDC_PROVIDER_URL)) {
495
+ verbose(`Found existing OIDC provider: ${provider.Arn}`);
496
+ return {
497
+ exists: true,
498
+ arn: provider.Arn
499
+ };
500
+ }
501
+ } catch {
502
+ }
503
+ }
504
+ verbose(`No existing OIDC provider found for ${OIDC_PROVIDER_URL}`);
505
+ return { exists: false };
506
+ } catch (error2) {
507
+ verbose(`Error checking OIDC providers: ${error2 instanceof Error ? error2.message : String(error2)}`);
508
+ return { exists: false };
509
+ }
510
+ }
511
+ function getOidcThumbprint() {
512
+ return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
513
+ }
514
+
515
+ // src/auth/browser-auth.ts
516
+ import express from "express";
517
+ import open from "open";
518
+ import { createServer } from "http";
519
+
520
+ // src/utils/validation.ts
521
+ var AWS_ACCOUNT_ID_REGEX = /^\d{12}$/;
522
+ var AWS_REGION_REGEX = /^[a-z]{2}-[a-z]+-\d$/;
523
+ var VALID_AWS_REGIONS = /* @__PURE__ */ new Set([
524
+ // US regions
525
+ "us-east-1",
526
+ "us-east-2",
527
+ "us-west-1",
528
+ "us-west-2",
529
+ // EU regions
530
+ "eu-west-1",
531
+ "eu-west-2",
532
+ "eu-west-3",
533
+ "eu-central-1",
534
+ "eu-central-2",
535
+ "eu-north-1",
536
+ "eu-south-1",
537
+ "eu-south-2",
538
+ // Asia Pacific regions
539
+ "ap-east-1",
540
+ "ap-south-1",
541
+ "ap-south-2",
542
+ "ap-northeast-1",
543
+ "ap-northeast-2",
544
+ "ap-northeast-3",
545
+ "ap-southeast-1",
546
+ "ap-southeast-2",
547
+ "ap-southeast-3",
548
+ "ap-southeast-4",
549
+ // South America
550
+ "sa-east-1",
551
+ // Middle East
552
+ "me-south-1",
553
+ "me-central-1",
554
+ // Africa
555
+ "af-south-1",
556
+ // Canada
557
+ "ca-central-1",
558
+ "ca-west-1",
559
+ // China (special)
560
+ "cn-north-1",
561
+ "cn-northwest-1",
562
+ // GovCloud
563
+ "us-gov-east-1",
564
+ "us-gov-west-1",
565
+ // Israel
566
+ "il-central-1"
567
+ ]);
568
+ function isValidAwsAccountId(accountId) {
569
+ return AWS_ACCOUNT_ID_REGEX.test(accountId);
570
+ }
571
+ function isValidAwsRegion(region) {
572
+ return AWS_REGION_REGEX.test(region) || VALID_AWS_REGIONS.has(region);
573
+ }
574
+
575
+ // src/auth/pkce.ts
576
+ import { randomBytes, createHash } from "crypto";
577
+ var UNRESERVED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
578
+ function generateCodeVerifier() {
579
+ const length = 128;
580
+ const bytes = randomBytes(length);
581
+ let verifier = "";
582
+ for (let i = 0; i < length; i++) {
583
+ verifier += UNRESERVED_CHARS[bytes[i] % UNRESERVED_CHARS.length];
584
+ }
585
+ return verifier;
586
+ }
587
+ function generateCodeChallenge(verifier) {
588
+ const hash = createHash("sha256").update(verifier).digest();
589
+ return hash.toString("base64url");
590
+ }
591
+ function generateState() {
592
+ return randomBytes(24).toString("base64url");
593
+ }
594
+
595
+ // src/auth/browser-auth.ts
596
+ var DEFAULT_AUTH_BASE_URL = "https://devramps.com";
597
+ var AUTHORIZE_PATH = "/oauth/authorize";
598
+ var TOKEN_PATH = "/oauth/token";
599
+ var CLI_CLIENT_ID = "devramps-cli";
600
+ var AUTH_TIMEOUT_MS = 3e5;
601
+ async function authenticateViaBrowser(options = {}) {
602
+ const baseUrl = options.endpointOverride || DEFAULT_AUTH_BASE_URL;
603
+ info("Opening browser for authentication...");
604
+ if (options.endpointOverride) {
605
+ warn(`Using endpoint override: ${options.endpointOverride}`);
606
+ }
607
+ verbose("Starting local callback server...");
608
+ const codeVerifier = generateCodeVerifier();
609
+ const codeChallenge = generateCodeChallenge(codeVerifier);
610
+ const state = generateState();
611
+ verbose("Generated PKCE code_challenge and state");
612
+ const { server, port, callbackPromise } = await startCallbackServer(state);
613
+ try {
614
+ const redirectUri = `http://localhost:${port}`;
615
+ const authParams = new URLSearchParams({
616
+ response_type: "code",
617
+ client_id: CLI_CLIENT_ID,
618
+ redirect_uri: redirectUri,
619
+ code_challenge: codeChallenge,
620
+ code_challenge_method: "S256",
621
+ state
622
+ });
623
+ const authUrl = `${baseUrl}${AUTHORIZE_PATH}?${authParams.toString()}`;
624
+ verbose(`Auth URL: ${authUrl}`);
625
+ verbose(`Redirect URI: ${redirectUri}`);
626
+ await open(authUrl);
627
+ info("Waiting for authentication...");
628
+ verbose("Complete the authentication in your browser.");
629
+ const callbackResult = await Promise.race([
630
+ callbackPromise,
631
+ timeout(AUTH_TIMEOUT_MS)
632
+ ]);
633
+ if (!callbackResult) {
634
+ throw new AuthenticationError("Authentication timed out. Please try again.");
635
+ }
636
+ if (callbackResult.error) {
637
+ const errorMsg = callbackResult.errorDescription || callbackResult.error;
638
+ throw new AuthenticationError(errorMsg);
639
+ }
640
+ if (!callbackResult.code) {
641
+ throw new AuthenticationError("No authorization code received. Please try again.");
642
+ }
643
+ if (callbackResult.state !== state) {
644
+ throw new AuthenticationError("State mismatch - possible CSRF attack. Please try again.");
645
+ }
646
+ verbose("Received authorization code, exchanging for access token...");
647
+ const tokenResponse = await exchangeCodeForToken({
648
+ baseUrl,
649
+ code: callbackResult.code,
650
+ redirectUri,
651
+ codeVerifier
652
+ });
653
+ if (!tokenResponse.organization_id) {
654
+ throw new AuthenticationError("No organization ID in token response. Please try again.");
655
+ }
656
+ verbose("Fetching organization details...");
657
+ const orgResponse = await fetchOrganization({
658
+ baseUrl,
659
+ accessToken: tokenResponse.access_token,
660
+ organizationId: tokenResponse.organization_id
661
+ });
662
+ const awsConfig = await fetchAwsConfiguration({
663
+ baseUrl,
664
+ accessToken: tokenResponse.access_token,
665
+ organizationId: tokenResponse.organization_id
666
+ });
667
+ const cicdAccountId = awsConfig.cicdAccountId || awsConfig.cicdAccount?.accountId;
668
+ if (!cicdAccountId) {
669
+ throw new AuthenticationError("No CI/CD account configured for this organization. Please configure one in the DevRamps dashboard.");
670
+ }
671
+ if (!isValidAwsAccountId(cicdAccountId)) {
672
+ throw new AuthenticationError("Invalid CI/CD account ID format.");
673
+ }
674
+ if (!awsConfig.defaultRegion || !isValidAwsRegion(awsConfig.defaultRegion)) {
675
+ throw new AuthenticationError("Invalid or missing default AWS region.");
676
+ }
677
+ success(`Authenticated with organization: ${orgResponse.slug}`);
678
+ verbose(`CI/CD Account: ${cicdAccountId}, Region: ${awsConfig.defaultRegion}`);
679
+ return {
680
+ orgSlug: orgResponse.slug,
681
+ cicdAccountId,
682
+ cicdRegion: awsConfig.defaultRegion
683
+ };
684
+ } finally {
685
+ await closeServer(server);
686
+ }
687
+ }
688
+ async function exchangeCodeForToken(params) {
689
+ const tokenUrl = `${params.baseUrl}${TOKEN_PATH}`;
690
+ const body = new URLSearchParams({
691
+ grant_type: "authorization_code",
692
+ client_id: CLI_CLIENT_ID,
693
+ code: params.code,
694
+ redirect_uri: params.redirectUri,
695
+ code_verifier: params.codeVerifier
696
+ });
697
+ verbose(`Token exchange URL: ${tokenUrl}`);
698
+ const response = await fetch(tokenUrl, {
699
+ method: "POST",
700
+ headers: {
701
+ "Content-Type": "application/x-www-form-urlencoded",
702
+ Accept: "application/json"
703
+ },
704
+ body: body.toString()
705
+ });
706
+ if (!response.ok) {
707
+ let errorMessage = `Token exchange failed with status ${response.status}`;
708
+ try {
709
+ const errorBody = await response.json();
710
+ if (errorBody.error_description) {
711
+ errorMessage = errorBody.error_description;
712
+ } else if (errorBody.error) {
713
+ errorMessage = `Token exchange failed: ${errorBody.error}`;
714
+ }
715
+ } catch {
716
+ }
717
+ throw new AuthenticationError(errorMessage);
718
+ }
719
+ const tokenResponse = await response.json();
720
+ if (!tokenResponse.access_token) {
721
+ throw new AuthenticationError("No access token in response");
722
+ }
723
+ verbose(`Token response: organization_id=${tokenResponse.organization_id}, scope=${tokenResponse.scope}, expires_in=${tokenResponse.expires_in}`);
724
+ return tokenResponse;
725
+ }
726
+ async function fetchOrganization(params) {
727
+ const url = `${params.baseUrl}/api/v1/organizations/${params.organizationId}`;
728
+ verbose(`Fetching organization: GET ${url}`);
729
+ const response = await fetch(url, {
730
+ method: "GET",
731
+ headers: {
732
+ Authorization: `Bearer ${params.accessToken}`,
733
+ Accept: "application/json"
734
+ }
735
+ });
736
+ verbose(`Organization response status: ${response.status}`);
737
+ if (!response.ok) {
738
+ const errorText = await response.text();
739
+ verbose(`Organization error response: ${errorText}`);
740
+ throw new AuthenticationError(`Failed to fetch organization: ${response.status}`);
741
+ }
742
+ const data = await response.json();
743
+ verbose(`Organization data: id=${data.id}, name=${data.name}, slug=${data.slug}`);
744
+ return data;
745
+ }
746
+ async function fetchAwsConfiguration(params) {
747
+ const url = `${params.baseUrl}/api/v1/organizations/${params.organizationId}/aws/configuration`;
748
+ verbose(`Fetching AWS configuration: GET ${url}`);
749
+ const response = await fetch(url, {
750
+ method: "GET",
751
+ headers: {
752
+ Authorization: `Bearer ${params.accessToken}`,
753
+ Accept: "application/json"
754
+ }
755
+ });
756
+ verbose(`AWS configuration response status: ${response.status}`);
757
+ if (!response.ok) {
758
+ const errorText = await response.text();
759
+ verbose(`AWS configuration error response: ${errorText}`);
760
+ throw new AuthenticationError(`Failed to fetch AWS configuration: ${response.status}`);
761
+ }
762
+ const data = await response.json();
763
+ verbose(`AWS configuration data: defaultRegion=${data.defaultRegion}, cicdAccountId=${data.cicdAccountId}, cicdAccount=${JSON.stringify(data.cicdAccount)}`);
764
+ return data;
765
+ }
766
+ async function startCallbackServer(expectedState) {
767
+ const app = express();
768
+ let resolveCallback;
769
+ const callbackPromise = new Promise((resolve) => {
770
+ resolveCallback = resolve;
771
+ });
772
+ app.get("/", (req, res) => {
773
+ const { code, state, error: error2, error_description } = req.query;
774
+ if (error2) {
775
+ res.send(errorPage(String(error_description || error2)));
776
+ resolveCallback({
777
+ error: String(error2),
778
+ errorDescription: error_description ? String(error_description) : void 0
779
+ });
780
+ return;
781
+ }
782
+ if (!state || state !== expectedState) {
783
+ res.send(errorPage("Invalid state parameter - possible CSRF attack"));
784
+ resolveCallback({ error: "state_mismatch" });
785
+ return;
786
+ }
787
+ if (!code || typeof code !== "string") {
788
+ res.send(errorPage("No authorization code received"));
789
+ resolveCallback({ error: "missing_code" });
790
+ return;
791
+ }
792
+ res.send(successPage());
793
+ resolveCallback({
794
+ code,
795
+ state: String(state)
796
+ });
797
+ });
798
+ return new Promise((resolve, reject) => {
799
+ const server = createServer(app);
800
+ server.listen(0, "127.0.0.1", () => {
801
+ const address = server.address();
802
+ if (!address || typeof address === "string") {
803
+ reject(new Error("Failed to get server address"));
804
+ return;
805
+ }
806
+ const port = address.port;
807
+ verbose(`Callback server listening on port ${port}`);
808
+ resolve({ server, port, callbackPromise });
809
+ });
810
+ server.on("error", reject);
811
+ });
812
+ }
813
+ async function closeServer(server) {
814
+ return new Promise((resolve) => {
815
+ server.close(() => {
816
+ verbose("Callback server closed");
817
+ resolve();
818
+ });
819
+ });
820
+ }
821
+ function timeout(ms) {
822
+ return new Promise((_, reject) => {
823
+ setTimeout(() => {
824
+ reject(new AuthenticationError("Authentication timed out"));
825
+ }, ms);
826
+ });
827
+ }
828
+ function successPage() {
829
+ return `
830
+ <!DOCTYPE html>
831
+ <html>
832
+ <head>
833
+ <title>DevRamps - Authentication Successful</title>
834
+ <style>
835
+ body {
836
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
837
+ display: flex;
838
+ justify-content: center;
839
+ align-items: center;
840
+ min-height: 100vh;
841
+ margin: 0;
842
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
843
+ color: white;
844
+ }
845
+ .container {
846
+ text-align: center;
847
+ padding: 2rem;
848
+ }
849
+ .checkmark {
850
+ font-size: 4rem;
851
+ margin-bottom: 1rem;
852
+ }
853
+ h1 {
854
+ margin: 0 0 0.5rem 0;
855
+ }
856
+ p {
857
+ opacity: 0.9;
858
+ }
859
+ </style>
860
+ </head>
861
+ <body>
862
+ <div class="container">
863
+ <div class="checkmark">&#10003;</div>
864
+ <h1>Authentication Successful</h1>
865
+ <p>You can close this window and return to your terminal.</p>
866
+ </div>
867
+ </body>
868
+ </html>
869
+ `;
870
+ }
871
+ function errorPage(error2) {
872
+ return `
873
+ <!DOCTYPE html>
874
+ <html>
875
+ <head>
876
+ <title>DevRamps - Authentication Failed</title>
877
+ <style>
878
+ body {
879
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
880
+ display: flex;
881
+ justify-content: center;
882
+ align-items: center;
883
+ min-height: 100vh;
884
+ margin: 0;
885
+ background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
886
+ color: white;
887
+ }
888
+ .container {
889
+ text-align: center;
890
+ padding: 2rem;
891
+ }
892
+ .icon {
893
+ font-size: 4rem;
894
+ margin-bottom: 1rem;
895
+ }
896
+ h1 {
897
+ margin: 0 0 0.5rem 0;
898
+ }
899
+ p {
900
+ opacity: 0.9;
901
+ }
902
+ .error {
903
+ background: rgba(255,255,255,0.2);
904
+ padding: 0.5rem 1rem;
905
+ border-radius: 4px;
906
+ display: inline-block;
907
+ margin-top: 1rem;
908
+ }
909
+ </style>
910
+ </head>
911
+ <body>
912
+ <div class="container">
913
+ <div class="icon">&#10007;</div>
914
+ <h1>Authentication Failed</h1>
915
+ <p>Please close this window and try again in your terminal.</p>
916
+ <div class="error">${error2}</div>
917
+ </div>
918
+ </body>
919
+ </html>
920
+ `;
921
+ }
922
+
923
+ // src/parsers/pipeline.ts
924
+ import { readFile as readFile2, readdir, access as access2, constants as constants2 } from "fs/promises";
925
+ import { join as join2 } from "path";
926
+ import { parse as parseYaml2 } from "yaml";
927
+
928
+ // src/parsers/additional-policies.ts
929
+ import { readFile, access, constants } from "fs/promises";
930
+ import { join } from "path";
931
+ import { parse as parseYaml } from "yaml";
932
+ var POLICIES_JSON = "aws_additional_iam_policies.json";
933
+ var POLICIES_YAML = "aws_additional_iam_policies.yaml";
934
+ async function parseAdditionalPolicies(pipelineDir) {
935
+ const jsonPath = join(pipelineDir, POLICIES_JSON);
936
+ const yamlPath = join(pipelineDir, POLICIES_YAML);
937
+ let content;
938
+ let format;
939
+ try {
940
+ await access(jsonPath, constants.R_OK);
941
+ content = await readFile(jsonPath, "utf-8");
942
+ format = "json";
943
+ verbose(`Found additional policies: ${POLICIES_JSON}`);
944
+ } catch {
945
+ try {
946
+ await access(yamlPath, constants.R_OK);
947
+ content = await readFile(yamlPath, "utf-8");
948
+ format = "yaml";
949
+ verbose(`Found additional policies: ${POLICIES_YAML}`);
950
+ } catch {
951
+ return [];
952
+ }
953
+ }
954
+ if (!content || !format) {
955
+ return [];
956
+ }
957
+ let policies;
958
+ try {
959
+ if (format === "json") {
960
+ policies = JSON.parse(content);
961
+ } else {
962
+ policies = parseYaml(content);
963
+ }
964
+ } catch (error2) {
965
+ throw new Error(`Failed to parse ${format === "json" ? POLICIES_JSON : POLICIES_YAML}: ${error2 instanceof Error ? error2.message : String(error2)}`);
966
+ }
967
+ if (!Array.isArray(policies)) {
968
+ throw new Error(`Additional policies file must contain an array of IAM policies`);
969
+ }
970
+ const validatedPolicies = [];
971
+ for (let i = 0; i < policies.length; i++) {
972
+ const policy = policies[i];
973
+ if (!policy || typeof policy !== "object") {
974
+ throw new Error(`Policy at index ${i} is not an object`);
975
+ }
976
+ if (!("Statement" in policy) || !Array.isArray(policy.Statement)) {
977
+ throw new Error(`Policy at index ${i} is missing Statement array`);
978
+ }
979
+ validatedPolicies.push(policy);
980
+ }
981
+ verbose(`Loaded ${validatedPolicies.length} additional policies`);
982
+ return validatedPolicies;
983
+ }
984
+
985
+ // src/parsers/pipeline.ts
986
+ var DEVRAMPS_FOLDER = ".devramps";
987
+ var PIPELINE_FILE = "pipeline.yaml";
988
+ async function findDevrampsPipelines(basePath, filterSlugs) {
989
+ const devrampsPath = join2(basePath, DEVRAMPS_FOLDER);
990
+ try {
991
+ await access2(devrampsPath, constants2.R_OK);
992
+ } catch {
993
+ throw new NoDevrampsFolderError();
994
+ }
995
+ const entries = await readdir(devrampsPath, { withFileTypes: true });
996
+ const pipelineSlugs = [];
997
+ for (const entry of entries) {
998
+ if (!entry.isDirectory()) continue;
999
+ const pipelinePath = join2(devrampsPath, entry.name, PIPELINE_FILE);
1000
+ try {
1001
+ await access2(pipelinePath, constants2.R_OK);
1002
+ pipelineSlugs.push(entry.name);
1003
+ } catch {
1004
+ verbose(`Skipping ${entry.name}: no pipeline.yaml found`);
1005
+ }
1006
+ }
1007
+ if (filterSlugs && filterSlugs.length > 0) {
1008
+ const filtered = pipelineSlugs.filter((slug) => filterSlugs.includes(slug));
1009
+ for (const slug of filterSlugs) {
1010
+ if (!pipelineSlugs.includes(slug)) {
1011
+ warn(`Pipeline '${slug}' not found in ${DEVRAMPS_FOLDER}/`);
1012
+ }
1013
+ }
1014
+ return filtered;
1015
+ }
1016
+ return pipelineSlugs;
1017
+ }
1018
+ async function parsePipeline(basePath, slug) {
1019
+ const pipelinePath = join2(basePath, DEVRAMPS_FOLDER, slug, PIPELINE_FILE);
1020
+ verbose(`Parsing pipeline: ${pipelinePath}`);
1021
+ let content;
1022
+ try {
1023
+ content = await readFile2(pipelinePath, "utf-8");
1024
+ } catch (error2) {
1025
+ throw new PipelineParseError(slug, `Could not read file: ${error2 instanceof Error ? error2.message : String(error2)}`);
1026
+ }
1027
+ let definition;
1028
+ try {
1029
+ definition = parseYaml2(content);
1030
+ } catch (error2) {
1031
+ throw new PipelineParseError(slug, `Invalid YAML: ${error2 instanceof Error ? error2.message : String(error2)}`);
1032
+ }
1033
+ if (!definition.pipeline) {
1034
+ throw new PipelineParseError(slug, 'Missing "pipeline" key in definition');
1035
+ }
1036
+ if (!definition.pipeline.stages || definition.pipeline.stages.length === 0) {
1037
+ throw new PipelineParseError(slug, "Pipeline must have at least one stage");
1038
+ }
1039
+ for (const stage of definition.pipeline.stages) {
1040
+ if (!stage.account_id) {
1041
+ throw new PipelineParseError(slug, `Stage "${stage.name}" is missing account_id`);
1042
+ }
1043
+ if (!stage.region) {
1044
+ throw new PipelineParseError(slug, `Stage "${stage.name}" is missing region`);
1045
+ }
1046
+ }
1047
+ const targetAccountIds = extractTargetAccountIds(definition);
1048
+ const steps = extractSteps(definition);
1049
+ const additionalPolicies = await parseAdditionalPoliciesForPipeline(basePath, slug);
1050
+ verbose(`Pipeline ${slug}: ${targetAccountIds.length} accounts, ${steps.length} steps`);
1051
+ return {
1052
+ slug,
1053
+ definition,
1054
+ targetAccountIds,
1055
+ stages: definition.pipeline.stages,
1056
+ steps,
1057
+ additionalPolicies
1058
+ };
1059
+ }
1060
+ function extractTargetAccountIds(definition) {
1061
+ const accountIds = /* @__PURE__ */ new Set();
1062
+ for (const stage of definition.pipeline.stages) {
1063
+ if (stage.account_id) {
1064
+ accountIds.add(stage.account_id);
1065
+ }
1066
+ }
1067
+ return Array.from(accountIds);
1068
+ }
1069
+ function extractSteps(definition) {
1070
+ return definition.pipeline.steps || [];
1071
+ }
1072
+ async function parseAdditionalPoliciesForPipeline(basePath, slug) {
1073
+ const pipelineDir = join2(basePath, DEVRAMPS_FOLDER, slug);
1074
+ try {
1075
+ return await parseAdditionalPolicies(pipelineDir);
1076
+ } catch (error2) {
1077
+ verbose(`No additional policies for ${slug}: ${error2 instanceof Error ? error2.message : String(error2)}`);
1078
+ return [];
1079
+ }
1080
+ }
1081
+
1082
+ // src/parsers/artifacts.ts
1083
+ var DOCKER_TYPES = ["DEVRAMPS:DOCKER:BUILD", "DEVRAMPS:DOCKER:IMPORT"];
1084
+ var BUNDLE_TYPES = ["DEVRAMPS:BUNDLE:BUILD", "DEVRAMPS:BUNDLE:IMPORT"];
1085
+ var VALID_TYPES = [...DOCKER_TYPES, ...BUNDLE_TYPES];
1086
+ function parseArtifacts(definition) {
1087
+ const docker = [];
1088
+ const bundle = [];
1089
+ const rawArtifacts = definition.pipeline.artifacts;
1090
+ if (!rawArtifacts) {
1091
+ return { docker, bundle };
1092
+ }
1093
+ for (const [name, raw] of Object.entries(rawArtifacts)) {
1094
+ const artifact = parseArtifact(name, raw);
1095
+ if (!artifact) {
1096
+ continue;
1097
+ }
1098
+ if (DOCKER_TYPES.includes(artifact.type)) {
1099
+ docker.push(artifact);
1100
+ } else if (BUNDLE_TYPES.includes(artifact.type)) {
1101
+ bundle.push(artifact);
1102
+ }
1103
+ }
1104
+ verbose(`Parsed artifacts: ${docker.length} docker, ${bundle.length} bundle`);
1105
+ return { docker, bundle };
1106
+ }
1107
+ function parseArtifact(name, raw) {
1108
+ if (!raw.type) {
1109
+ warn(`Artifact "${name}" is missing type, skipping`);
1110
+ return null;
1111
+ }
1112
+ if (!VALID_TYPES.includes(raw.type)) {
1113
+ warn(`Artifact "${name}" has unknown type "${raw.type}", skipping`);
1114
+ return null;
1115
+ }
1116
+ const base = {
1117
+ name,
1118
+ id: raw.id,
1119
+ type: raw.type,
1120
+ architecture: raw.architecture,
1121
+ host_size: raw.host_size,
1122
+ per_stage: raw.per_stage,
1123
+ rebuild_when_changed: raw.rebuild_when_changed,
1124
+ dependencies: raw.dependencies,
1125
+ params: raw.params
1126
+ };
1127
+ switch (raw.type) {
1128
+ case "DEVRAMPS:DOCKER:BUILD":
1129
+ return base;
1130
+ case "DEVRAMPS:DOCKER:IMPORT":
1131
+ return base;
1132
+ case "DEVRAMPS:BUNDLE:BUILD":
1133
+ return base;
1134
+ case "DEVRAMPS:BUNDLE:IMPORT":
1135
+ return base;
1136
+ default:
1137
+ return null;
1138
+ }
1139
+ }
1140
+ function filterArtifactsForPipelineStack(artifacts) {
1141
+ return {
1142
+ docker: artifacts.docker.filter((a) => !a.per_stage),
1143
+ bundle: artifacts.bundle.filter((a) => !a.per_stage)
1144
+ };
1145
+ }
1146
+ function getArtifactId(artifact) {
1147
+ if (artifact.id) {
1148
+ return artifact.id;
1149
+ }
1150
+ return artifact.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1151
+ }
1152
+
1153
+ // src/templates/common.ts
1154
+ var STANDARD_TAGS = [
1155
+ { Key: "CreatedBy", Value: "DevRamps" },
1156
+ { Key: "ManagedBy", Value: "DevRamps-CLI" }
1157
+ ];
1158
+ function createBaseTemplate(description) {
1159
+ return {
1160
+ AWSTemplateFormatVersion: "2010-09-09",
1161
+ Description: description,
1162
+ Parameters: {},
1163
+ Conditions: {},
1164
+ Resources: {},
1165
+ Outputs: {}
1166
+ };
1167
+ }
1168
+ function sanitizeResourceId(name) {
1169
+ return name.replace(/[^a-zA-Z0-9]/g, "").substring(0, 64);
1170
+ }
1171
+ function addOidcProviderResource(template, conditional = true) {
1172
+ if (conditional) {
1173
+ template.Parameters.OIDCProviderExists = {
1174
+ Type: "String",
1175
+ Default: "false",
1176
+ AllowedValues: ["true", "false"],
1177
+ Description: "Whether the OIDC provider already exists in this account"
1178
+ };
1179
+ template.Conditions.CreateOIDCProvider = {
1180
+ "Fn::Equals": [{ Ref: "OIDCProviderExists" }, "false"]
1181
+ };
1182
+ }
1183
+ template.Resources.DevRampsOIDCProvider = {
1184
+ Type: "AWS::IAM::OIDCProvider",
1185
+ ...conditional ? { Condition: "CreateOIDCProvider" } : {},
1186
+ Properties: {
1187
+ Url: `https://${OIDC_PROVIDER_URL}`,
1188
+ ClientIdList: [OIDC_PROVIDER_URL],
1189
+ ThumbprintList: [getOidcThumbprint()],
1190
+ Tags: STANDARD_TAGS
1191
+ }
1192
+ };
1193
+ }
1194
+ function getOidcProviderArn(accountId, conditional = true) {
1195
+ if (conditional) {
1196
+ return {
1197
+ "Fn::If": [
1198
+ "CreateOIDCProvider",
1199
+ { "Fn::GetAtt": ["DevRampsOIDCProvider", "Arn"] },
1200
+ `arn:aws:iam::${accountId}:oidc-provider/${OIDC_PROVIDER_URL}`
1201
+ ]
1202
+ };
1203
+ }
1204
+ return { "Fn::GetAtt": ["DevRampsOIDCProvider", "Arn"] };
1205
+ }
1206
+ function buildOidcTrustPolicy(accountId, subject) {
1207
+ return {
1208
+ Version: "2012-10-17",
1209
+ Statement: [
1210
+ {
1211
+ Effect: "Allow",
1212
+ Principal: {
1213
+ Federated: `arn:aws:iam::${accountId}:oidc-provider/${OIDC_PROVIDER_URL}`
1214
+ },
1215
+ Action: "sts:AssumeRoleWithWebIdentity",
1216
+ Condition: {
1217
+ StringEquals: {
1218
+ [`${OIDC_PROVIDER_URL}:sub`]: subject,
1219
+ [`${OIDC_PROVIDER_URL}:aud`]: OIDC_PROVIDER_URL
1220
+ }
1221
+ }
1222
+ }
1223
+ ]
1224
+ };
1225
+ }
1226
+ function createIamRoleResource(roleName, trustPolicy, policies, additionalTags = []) {
1227
+ return {
1228
+ Type: "AWS::IAM::Role",
1229
+ Properties: {
1230
+ RoleName: roleName,
1231
+ AssumeRolePolicyDocument: trustPolicy,
1232
+ ...policies && policies.length > 0 ? { Policies: policies } : {},
1233
+ Tags: [...STANDARD_TAGS, ...additionalTags]
1234
+ }
1235
+ };
1236
+ }
1237
+ function createS3BucketResource(bucketName, additionalTags = [], encryption) {
1238
+ const encryptionConfig = encryption?.kmsKeyArn ? {
1239
+ ServerSideEncryptionConfiguration: [
1240
+ {
1241
+ ServerSideEncryptionByDefault: {
1242
+ SSEAlgorithm: "aws:kms",
1243
+ KMSMasterKeyID: encryption.kmsKeyArn
1244
+ }
1245
+ }
1246
+ ]
1247
+ } : {
1248
+ ServerSideEncryptionConfiguration: [
1249
+ {
1250
+ ServerSideEncryptionByDefault: {
1251
+ SSEAlgorithm: "AES256"
1252
+ }
1253
+ }
1254
+ ]
1255
+ };
1256
+ return {
1257
+ Type: "AWS::S3::Bucket",
1258
+ Properties: {
1259
+ BucketName: bucketName,
1260
+ VersioningConfiguration: { Status: "Enabled" },
1261
+ BucketEncryption: encryptionConfig,
1262
+ PublicAccessBlockConfiguration: {
1263
+ BlockPublicAcls: true,
1264
+ BlockPublicPolicy: true,
1265
+ IgnorePublicAcls: true,
1266
+ RestrictPublicBuckets: true
1267
+ },
1268
+ Tags: [...STANDARD_TAGS, ...additionalTags]
1269
+ }
1270
+ };
1271
+ }
1272
+ function createEcrRepositoryResource(repositoryName, additionalTags = []) {
1273
+ return {
1274
+ Type: "AWS::ECR::Repository",
1275
+ Properties: {
1276
+ RepositoryName: repositoryName,
1277
+ ImageScanningConfiguration: { ScanOnPush: true },
1278
+ EncryptionConfiguration: { EncryptionType: "AES256" },
1279
+ Tags: [...STANDARD_TAGS, ...additionalTags]
1280
+ }
1281
+ };
1282
+ }
1283
+ function createKmsKeyResource(description, keyPolicy, additionalTags = []) {
1284
+ return {
1285
+ Type: "AWS::KMS::Key",
1286
+ Properties: {
1287
+ Description: description,
1288
+ EnableKeyRotation: true,
1289
+ KeyPolicy: keyPolicy,
1290
+ Tags: [...STANDARD_TAGS, ...additionalTags]
1291
+ }
1292
+ };
1293
+ }
1294
+ function createKmsKeyAliasResource(aliasName, targetKeyRef) {
1295
+ return {
1296
+ Type: "AWS::KMS::Alias",
1297
+ Properties: {
1298
+ AliasName: aliasName,
1299
+ TargetKeyId: { Ref: targetKeyRef }
1300
+ }
1301
+ };
1302
+ }
1303
+
1304
+ // src/naming/index.ts
1305
+ var S3_BUCKET_MAX_LENGTH = 63;
1306
+ var ECR_REPO_MAX_LENGTH = 256;
1307
+ var IAM_ROLE_MAX_LENGTH = 64;
1308
+ var CF_STACK_MAX_LENGTH = 128;
1309
+ function normalizeName(name) {
1310
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1311
+ }
1312
+ function generateShortHash(input, length = 6) {
1313
+ let hash = 0;
1314
+ for (let i = 0; i < input.length; i++) {
1315
+ const char = input.charCodeAt(i);
1316
+ hash = (hash << 5) - hash + char;
1317
+ hash = hash & hash;
1318
+ }
1319
+ return Math.abs(hash).toString(36).substring(0, length).padStart(length, "0");
1320
+ }
1321
+ function truncateName(name, maxLength, hashLength = 6) {
1322
+ if (name.length <= maxLength) {
1323
+ return name;
1324
+ }
1325
+ const availableLength = maxLength - hashLength - 1;
1326
+ const hash = generateShortHash(name, hashLength);
1327
+ return `${name.substring(0, availableLength)}-${hash}`;
1328
+ }
1329
+ function generateTerraformStateBucketName(orgSlug) {
1330
+ const normalized = normalizeName(`devramps-${orgSlug}-terraform-state`);
1331
+ return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
1332
+ }
1333
+ function generatePipelineBucketName(cicdAccountId, pipelineSlug, artifactId) {
1334
+ const normalized = normalizeName(`${cicdAccountId}-${pipelineSlug}-${artifactId}`);
1335
+ return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
1336
+ }
1337
+ function generateStageBucketName(accountId, pipelineSlug, stageName, artifactId) {
1338
+ const normalized = normalizeName(`${accountId}-${pipelineSlug}-${stageName}-${artifactId}`);
1339
+ return truncateName(normalized, S3_BUCKET_MAX_LENGTH);
1340
+ }
1341
+ function generatePipelineEcrRepoName(pipelineSlug, artifactId) {
1342
+ const normalized = normalizeName(`${pipelineSlug}-${artifactId}`);
1343
+ return truncateName(normalized, ECR_REPO_MAX_LENGTH);
1344
+ }
1345
+ function generateStageEcrRepoName(pipelineSlug, stageName, artifactId) {
1346
+ const normalized = normalizeName(`${pipelineSlug}-${stageName}-${artifactId}`);
1347
+ return truncateName(normalized, ECR_REPO_MAX_LENGTH);
1348
+ }
1349
+ function getOrgRoleName() {
1350
+ return "DevRamps-CICD-DeploymentRole";
1351
+ }
1352
+ function generateStageRoleName(pipelineSlug, stageName) {
1353
+ const baseName = `DevRamps-${pipelineSlug}-${stageName}-DeploymentRole`;
1354
+ return truncateName(baseName, IAM_ROLE_MAX_LENGTH);
1355
+ }
1356
+ function getOrgStackName(orgSlug) {
1357
+ return truncateName(`DevRamps-${orgSlug}-Org`, CF_STACK_MAX_LENGTH);
1358
+ }
1359
+ function getPipelineStackName(pipelineSlug) {
1360
+ return truncateName(`DevRamps-${pipelineSlug}-Pipeline`, CF_STACK_MAX_LENGTH);
1361
+ }
1362
+ function getStageStackName(pipelineSlug, stageName) {
1363
+ return truncateName(`DevRamps-${pipelineSlug}-${stageName}-Stage`, CF_STACK_MAX_LENGTH);
1364
+ }
1365
+ function getKmsKeyAlias(orgSlug) {
1366
+ return `alias/devramps-${normalizeName(orgSlug)}`;
1367
+ }
1368
+
1369
+ // src/merge/bucket-policy.ts
1370
+ import { S3Client, GetBucketPolicyCommand } from "@aws-sdk/client-s3";
1371
+
1372
+ // src/merge/strategy.ts
1373
+ var BaseMergeStrategy = class {
1374
+ /**
1375
+ * Default validation - always valid. Override for specific validation.
1376
+ */
1377
+ validate(result) {
1378
+ return { valid: true };
1379
+ }
1380
+ };
1381
+
1382
+ // src/merge/bucket-policy.ts
1383
+ var BucketPolicyMergeStrategy = class extends BaseMergeStrategy {
1384
+ strategyId = "terraform-state-bucket-policy";
1385
+ displayName = "Terraform State Bucket Policy";
1386
+ bucketName = null;
1387
+ credentials;
1388
+ region = "us-east-1";
1389
+ /**
1390
+ * Configure the strategy with bucket details
1391
+ */
1392
+ configure(bucketName, region, credentials) {
1393
+ this.bucketName = bucketName;
1394
+ this.region = region;
1395
+ this.credentials = credentials;
1396
+ }
1397
+ /**
1398
+ * Extract existing account IDs from the current bucket policy
1399
+ */
1400
+ async extractExisting(stackResources) {
1401
+ if (!this.bucketName) {
1402
+ verbose("No bucket name configured, cannot extract existing policy");
1403
+ return null;
1404
+ }
1405
+ try {
1406
+ const client = new S3Client({
1407
+ region: this.region,
1408
+ credentials: this.credentials
1409
+ });
1410
+ const response = await client.send(
1411
+ new GetBucketPolicyCommand({ Bucket: this.bucketName })
1412
+ );
1413
+ if (!response.Policy) {
1414
+ verbose("Bucket has no policy");
1415
+ return null;
1416
+ }
1417
+ const policy = JSON.parse(response.Policy);
1418
+ const accountIds = this.extractAccountIdsFromPolicy(policy);
1419
+ verbose(`Found ${accountIds.length} existing account(s) in bucket policy`);
1420
+ return { allowedAccountIds: accountIds };
1421
+ } catch (error2) {
1422
+ if (error2 instanceof Error && error2.name === "NoSuchBucketPolicy") {
1423
+ verbose("Bucket has no policy (NoSuchBucketPolicy)");
1424
+ return null;
1425
+ }
1426
+ if (error2 instanceof Error && error2.name === "NoSuchBucket") {
1427
+ verbose("Bucket does not exist yet");
1428
+ return null;
1429
+ }
1430
+ verbose(`Could not read bucket policy: ${error2 instanceof Error ? error2.message : String(error2)}`);
1431
+ return null;
1432
+ }
1433
+ }
1434
+ /**
1435
+ * Collect all target account IDs from all pipelines
1436
+ */
1437
+ async collectNew(context) {
1438
+ const accountIds = /* @__PURE__ */ new Set();
1439
+ if (!isValidAwsAccountId(context.cicdAccountId)) {
1440
+ throw new Error(
1441
+ `Invalid CI/CD account ID: "${context.cicdAccountId}". AWS account IDs must be exactly 12 digits.`
1442
+ );
1443
+ }
1444
+ accountIds.add(context.cicdAccountId);
1445
+ for (const pipeline of context.pipelines) {
1446
+ for (const accountId of pipeline.targetAccountIds) {
1447
+ if (!isValidAwsAccountId(accountId)) {
1448
+ throw new Error(
1449
+ `Invalid target account ID in pipeline "${pipeline.slug}": "${accountId}". AWS account IDs must be exactly 12 digits.`
1450
+ );
1451
+ }
1452
+ accountIds.add(accountId);
1453
+ }
1454
+ }
1455
+ verbose(`Collected ${accountIds.size} account(s) from pipelines`);
1456
+ return { allowedAccountIds: Array.from(accountIds) };
1457
+ }
1458
+ /**
1459
+ * Merge existing and new account IDs, deduplicating
1460
+ */
1461
+ merge(existing, newData) {
1462
+ const mergedAccountIds = /* @__PURE__ */ new Set();
1463
+ if (existing) {
1464
+ for (const accountId of existing.allowedAccountIds) {
1465
+ mergedAccountIds.add(accountId);
1466
+ }
1467
+ }
1468
+ for (const accountId of newData.allowedAccountIds) {
1469
+ mergedAccountIds.add(accountId);
1470
+ }
1471
+ const sorted = Array.from(mergedAccountIds).sort();
1472
+ verbose(`Merged to ${sorted.length} unique account(s)`);
1473
+ return { allowedAccountIds: sorted };
1474
+ }
1475
+ /**
1476
+ * Validate the merged result
1477
+ */
1478
+ validate(result) {
1479
+ const errors = [];
1480
+ const warnings = [];
1481
+ for (const accountId of result.allowedAccountIds) {
1482
+ if (!/^\d{12}$/.test(accountId)) {
1483
+ errors.push(`Invalid AWS account ID format: ${accountId}`);
1484
+ }
1485
+ }
1486
+ if (result.allowedAccountIds.length > 50) {
1487
+ warnings.push(
1488
+ `Large number of accounts (${result.allowedAccountIds.length}) in bucket policy. Consider using AWS Organizations conditions instead.`
1489
+ );
1490
+ }
1491
+ return {
1492
+ valid: errors.length === 0,
1493
+ errors: errors.length > 0 ? errors : void 0,
1494
+ warnings: warnings.length > 0 ? warnings : void 0
1495
+ };
1496
+ }
1497
+ /**
1498
+ * Extract account IDs from a bucket policy document
1499
+ */
1500
+ extractAccountIdsFromPolicy(policy) {
1501
+ const accountIds = [];
1502
+ if (!policy || typeof policy !== "object") {
1503
+ return accountIds;
1504
+ }
1505
+ const policyDoc = policy;
1506
+ if (!Array.isArray(policyDoc.Statement)) {
1507
+ return accountIds;
1508
+ }
1509
+ for (const statement of policyDoc.Statement) {
1510
+ if (!statement || typeof statement !== "object") continue;
1511
+ const stmt = statement;
1512
+ const principal = stmt.Principal?.AWS;
1513
+ if (!principal) continue;
1514
+ const principals = Array.isArray(principal) ? principal : [principal];
1515
+ for (const p of principals) {
1516
+ let extractedId = null;
1517
+ const arnMatch = p.match(/arn:aws:iam::(\d{12}):/);
1518
+ if (arnMatch) {
1519
+ extractedId = arnMatch[1];
1520
+ } else if (/^\d{12}$/.test(p)) {
1521
+ extractedId = p;
1522
+ }
1523
+ if (extractedId) {
1524
+ if (isValidAwsAccountId(extractedId)) {
1525
+ accountIds.push(extractedId);
1526
+ } else {
1527
+ verbose(`Skipping invalid account ID from existing policy: "${extractedId}"`);
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+ return [...new Set(accountIds)];
1533
+ }
1534
+ };
1535
+ function createTerraformStateBucketPolicy(bucketName, cicdAccountId, allowedAccountIds) {
1536
+ const accountStatements = allowedAccountIds.filter((id) => id !== cicdAccountId).map((accountId) => ({
1537
+ Sid: `AllowAccount${accountId}`,
1538
+ Effect: "Allow",
1539
+ Principal: {
1540
+ AWS: `arn:aws:iam::${accountId}:root`
1541
+ },
1542
+ Action: [
1543
+ "s3:GetObject",
1544
+ "s3:PutObject",
1545
+ "s3:DeleteObject"
1546
+ ],
1547
+ Resource: `arn:aws:s3:::${bucketName}/*`
1548
+ }));
1549
+ const listStatement = {
1550
+ Sid: "AllowListBucket",
1551
+ Effect: "Allow",
1552
+ Principal: {
1553
+ AWS: allowedAccountIds.map((id) => `arn:aws:iam::${id}:root`)
1554
+ },
1555
+ Action: "s3:ListBucket",
1556
+ Resource: `arn:aws:s3:::${bucketName}`
1557
+ };
1558
+ const cicdStatement = {
1559
+ Sid: "AllowCICDAccount",
1560
+ Effect: "Allow",
1561
+ Principal: {
1562
+ AWS: `arn:aws:iam::${cicdAccountId}:root`
1563
+ },
1564
+ Action: "s3:*",
1565
+ Resource: [
1566
+ `arn:aws:s3:::${bucketName}`,
1567
+ `arn:aws:s3:::${bucketName}/*`
1568
+ ]
1569
+ };
1570
+ return {
1571
+ Version: "2012-10-17",
1572
+ Statement: [cicdStatement, listStatement, ...accountStatements]
1573
+ };
1574
+ }
1575
+
1576
+ // src/templates/org-stack.ts
1577
+ function generateOrgStackTemplate(options) {
1578
+ const { orgSlug, cicdAccountId, targetAccountIds } = options;
1579
+ const template = createBaseTemplate(`DevRamps Org Stack for ${orgSlug}`);
1580
+ addOidcProviderResource(template, true);
1581
+ const kmsKeyPolicy = buildKmsKeyPolicy(cicdAccountId, targetAccountIds);
1582
+ template.Resources.DevRampsKMSKey = createKmsKeyResource(
1583
+ `DevRamps encryption key for org: ${orgSlug}`,
1584
+ kmsKeyPolicy,
1585
+ [{ Key: "Organization", Value: orgSlug }]
1586
+ );
1587
+ template.Resources.DevRampsKMSKeyAlias = createKmsKeyAliasResource(
1588
+ getKmsKeyAlias(orgSlug),
1589
+ "DevRampsKMSKey"
1590
+ );
1591
+ const bucketName = generateTerraformStateBucketName(orgSlug);
1592
+ template.Resources.TerraformStateBucket = createS3BucketResource(
1593
+ bucketName,
1594
+ [{ Key: "Organization", Value: orgSlug }],
1595
+ { kmsKeyArn: { "Fn::GetAtt": ["DevRampsKMSKey", "Arn"] } }
1596
+ );
1597
+ const bucketPolicy = createTerraformStateBucketPolicy(
1598
+ bucketName,
1599
+ cicdAccountId,
1600
+ targetAccountIds
1601
+ );
1602
+ template.Resources.TerraformStateBucketPolicy = {
1603
+ Type: "AWS::S3::BucketPolicy",
1604
+ Properties: {
1605
+ Bucket: { Ref: "TerraformStateBucket" },
1606
+ PolicyDocument: bucketPolicy
1607
+ }
1608
+ };
1609
+ const trustPolicy = buildOidcTrustPolicy(cicdAccountId, `org:${orgSlug}`);
1610
+ const orgRolePolicies = buildOrgRolePolicies(orgSlug);
1611
+ template.Resources.DevRampsCICDDeploymentRole = createIamRoleResource(
1612
+ getOrgRoleName(),
1613
+ trustPolicy,
1614
+ orgRolePolicies,
1615
+ [{ Key: "Organization", Value: orgSlug }]
1616
+ );
1617
+ template.Outputs = {
1618
+ OrgRoleArn: {
1619
+ Description: "ARN of the org-level CICD deployment role",
1620
+ Value: { "Fn::GetAtt": ["DevRampsCICDDeploymentRole", "Arn"] },
1621
+ Export: { Name: `DevRamps-${orgSlug}-OrgRoleArn` }
1622
+ },
1623
+ OrgRoleName: {
1624
+ Description: "Name of the org-level CICD deployment role",
1625
+ Value: { Ref: "DevRampsCICDDeploymentRole" }
1626
+ },
1627
+ KMSKeyArn: {
1628
+ Description: "ARN of the KMS encryption key",
1629
+ Value: { "Fn::GetAtt": ["DevRampsKMSKey", "Arn"] },
1630
+ Export: { Name: `DevRamps-${orgSlug}-KMSKeyArn` }
1631
+ },
1632
+ KMSKeyId: {
1633
+ Description: "ID of the KMS encryption key",
1634
+ Value: { Ref: "DevRampsKMSKey" }
1635
+ },
1636
+ TerraformStateBucketName: {
1637
+ Description: "Name of the Terraform state bucket",
1638
+ Value: { Ref: "TerraformStateBucket" },
1639
+ Export: { Name: `DevRamps-${orgSlug}-TerraformStateBucket` }
1640
+ },
1641
+ TerraformStateBucketArn: {
1642
+ Description: "ARN of the Terraform state bucket",
1643
+ Value: { "Fn::GetAtt": ["TerraformStateBucket", "Arn"] }
1644
+ },
1645
+ OIDCProviderArn: {
1646
+ Description: "ARN of the OIDC provider",
1647
+ Value: getOidcProviderArn(cicdAccountId, true)
1648
+ }
1649
+ };
1650
+ return template;
1651
+ }
1652
+ function buildKmsKeyPolicy(cicdAccountId, targetAccountIds) {
1653
+ const allAccountIds = [.../* @__PURE__ */ new Set([cicdAccountId, ...targetAccountIds])];
1654
+ return {
1655
+ Version: "2012-10-17",
1656
+ Statement: [
1657
+ {
1658
+ Sid: "EnableRootAccountPermissions",
1659
+ Effect: "Allow",
1660
+ Principal: {
1661
+ AWS: `arn:aws:iam::${cicdAccountId}:root`
1662
+ },
1663
+ Action: "kms:*",
1664
+ Resource: "*"
1665
+ },
1666
+ {
1667
+ Sid: "AllowTargetAccountsEncryptDecrypt",
1668
+ Effect: "Allow",
1669
+ Principal: {
1670
+ AWS: allAccountIds.map((id) => `arn:aws:iam::${id}:root`)
1671
+ },
1672
+ Action: [
1673
+ "kms:Encrypt",
1674
+ "kms:Decrypt",
1675
+ "kms:ReEncrypt*",
1676
+ "kms:GenerateDataKey*",
1677
+ "kms:DescribeKey"
1678
+ ],
1679
+ Resource: "*"
1680
+ }
1681
+ ]
1682
+ };
1683
+ }
1684
+ function buildOrgRolePolicies(orgSlug) {
1685
+ return [
1686
+ {
1687
+ PolicyName: "DevRampsOrgPolicy",
1688
+ PolicyDocument: {
1689
+ Version: "2012-10-17",
1690
+ Statement: [
1691
+ {
1692
+ Sid: "AllowAssumeStageRoles",
1693
+ Effect: "Allow",
1694
+ Action: "sts:AssumeRole",
1695
+ Resource: `arn:aws:iam::*:role/DevRamps-*-DeploymentRole`
1696
+ },
1697
+ {
1698
+ Sid: "AllowKMSUsage",
1699
+ Effect: "Allow",
1700
+ Action: [
1701
+ "kms:Encrypt",
1702
+ "kms:Decrypt",
1703
+ "kms:GenerateDataKey*"
1704
+ ],
1705
+ Resource: "*",
1706
+ Condition: {
1707
+ StringEquals: {
1708
+ "kms:CallerAccount": { Ref: "AWS::AccountId" }
1709
+ }
1710
+ }
1711
+ },
1712
+ {
1713
+ Sid: "AllowS3TerraformState",
1714
+ Effect: "Allow",
1715
+ Action: [
1716
+ "s3:GetObject",
1717
+ "s3:PutObject",
1718
+ "s3:DeleteObject",
1719
+ "s3:ListBucket"
1720
+ ],
1721
+ Resource: [
1722
+ { "Fn::GetAtt": ["TerraformStateBucket", "Arn"] },
1723
+ { "Fn::Sub": "${TerraformStateBucket.Arn}/*" }
1724
+ ]
1725
+ },
1726
+ {
1727
+ Sid: "AllowECROperations",
1728
+ Effect: "Allow",
1729
+ Action: [
1730
+ "ecr:GetAuthorizationToken",
1731
+ "ecr:BatchCheckLayerAvailability",
1732
+ "ecr:GetDownloadUrlForLayer",
1733
+ "ecr:BatchGetImage",
1734
+ "ecr:PutImage",
1735
+ "ecr:InitiateLayerUpload",
1736
+ "ecr:UploadLayerPart",
1737
+ "ecr:CompleteLayerUpload"
1738
+ ],
1739
+ Resource: "*"
1740
+ }
1741
+ ]
1742
+ }
1743
+ }
1744
+ ];
1745
+ }
1746
+
1747
+ // src/templates/pipeline-stack.ts
1748
+ function generatePipelineStackTemplate(options) {
1749
+ const { pipelineSlug, cicdAccountId, dockerArtifacts, bundleArtifacts } = options;
1750
+ const template = createBaseTemplate(`DevRamps Pipeline Stack for ${pipelineSlug}`);
1751
+ const ecrOutputs = {};
1752
+ const s3Outputs = {};
1753
+ for (const artifact of dockerArtifacts) {
1754
+ const artifactId = getArtifactId(artifact);
1755
+ const repoName = generatePipelineEcrRepoName(pipelineSlug, artifactId);
1756
+ const resourceId = sanitizeResourceId(`ECR${artifactId}`);
1757
+ template.Resources[resourceId] = createEcrRepositoryResource(
1758
+ repoName,
1759
+ [
1760
+ { Key: "Pipeline", Value: pipelineSlug },
1761
+ { Key: "Artifact", Value: artifact.name },
1762
+ { Key: "ArtifactType", Value: artifact.type }
1763
+ ]
1764
+ );
1765
+ ecrOutputs[artifact.name] = { repoName, resourceId };
1766
+ }
1767
+ for (const artifact of bundleArtifacts) {
1768
+ const artifactId = getArtifactId(artifact);
1769
+ const bucketName = generatePipelineBucketName(cicdAccountId, pipelineSlug, artifactId);
1770
+ const resourceId = sanitizeResourceId(`Bucket${artifactId}`);
1771
+ template.Resources[resourceId] = createS3BucketResource(
1772
+ bucketName,
1773
+ [
1774
+ { Key: "Pipeline", Value: pipelineSlug },
1775
+ { Key: "Artifact", Value: artifact.name },
1776
+ { Key: "ArtifactType", Value: artifact.type }
1777
+ ]
1778
+ );
1779
+ s3Outputs[artifact.name] = { bucketName, resourceId };
1780
+ }
1781
+ for (const [artifactName, { resourceId }] of Object.entries(ecrOutputs)) {
1782
+ const safeName = sanitizeResourceId(artifactName);
1783
+ template.Outputs[`${safeName}RepoUri`] = {
1784
+ Description: `ECR Repository URI for ${artifactName}`,
1785
+ Value: { "Fn::GetAtt": [resourceId, "RepositoryUri"] },
1786
+ Export: { Name: `DevRamps-${pipelineSlug}-${safeName}-RepoUri` }
1787
+ };
1788
+ template.Outputs[`${safeName}RepoArn`] = {
1789
+ Description: `ECR Repository ARN for ${artifactName}`,
1790
+ Value: { "Fn::GetAtt": [resourceId, "Arn"] }
1791
+ };
1792
+ }
1793
+ for (const [artifactName, { resourceId }] of Object.entries(s3Outputs)) {
1794
+ const safeName = sanitizeResourceId(artifactName);
1795
+ template.Outputs[`${safeName}BucketName`] = {
1796
+ Description: `S3 Bucket name for ${artifactName}`,
1797
+ Value: { Ref: resourceId },
1798
+ Export: { Name: `DevRamps-${pipelineSlug}-${safeName}-BucketName` }
1799
+ };
1800
+ template.Outputs[`${safeName}BucketArn`] = {
1801
+ Description: `S3 Bucket ARN for ${artifactName}`,
1802
+ Value: { "Fn::GetAtt": [resourceId, "Arn"] }
1803
+ };
1804
+ }
1805
+ template.Outputs.PipelineSlug = {
1806
+ Description: "Pipeline slug",
1807
+ Value: pipelineSlug
1808
+ };
1809
+ template.Outputs.ECRRepoCount = {
1810
+ Description: "Number of ECR repositories created",
1811
+ Value: String(Object.keys(ecrOutputs).length)
1812
+ };
1813
+ template.Outputs.S3BucketCount = {
1814
+ Description: "Number of S3 buckets created",
1815
+ Value: String(Object.keys(s3Outputs).length)
1816
+ };
1817
+ return template;
1818
+ }
1819
+
1820
+ // src/permissions/eks-deploy.ts
1821
+ var EKS_DEPLOY_PERMISSIONS = {
1822
+ actions: [
1823
+ // EKS cluster access
1824
+ "eks:DescribeCluster",
1825
+ "eks:AccessKubernetesApi",
1826
+ // EKS access entry management (for setting up kubectl access)
1827
+ "eks:CreateAccessEntry",
1828
+ "eks:DescribeAccessEntry",
1829
+ "eks:AssociateAccessPolicy"
1830
+ ],
1831
+ // Resources are scoped to the specific cluster at deployment time
1832
+ // '*' is used here as the specific cluster ARN is determined by the pipeline config
1833
+ resources: ["*"]
1834
+ };
1835
+
1836
+ // src/permissions/eks-helm.ts
1837
+ var EKS_HELM_PERMISSIONS = {
1838
+ actions: [
1839
+ // EKS cluster access
1840
+ "eks:DescribeCluster",
1841
+ "eks:AccessKubernetesApi",
1842
+ // EKS access entry management (for setting up kubectl/helm access)
1843
+ "eks:CreateAccessEntry",
1844
+ "eks:DescribeAccessEntry",
1845
+ "eks:AssociateAccessPolicy"
1846
+ ],
1847
+ // Resources are scoped to the specific cluster at deployment time
1848
+ resources: ["*"]
1849
+ };
1850
+
1851
+ // src/permissions/ecs-deploy.ts
1852
+ var ECS_DEPLOY_PERMISSIONS = {
1853
+ actions: [
1854
+ // ECS service operations
1855
+ "ecs:UpdateService",
1856
+ "ecs:DescribeServices",
1857
+ // Task definition operations
1858
+ "ecs:DescribeTaskDefinition",
1859
+ "ecs:RegisterTaskDefinition",
1860
+ // Required for ECS to use task/execution roles
1861
+ "iam:PassRole"
1862
+ ],
1863
+ // Resources are scoped to the specific cluster/service at deployment time
1864
+ resources: ["*"]
1865
+ };
1866
+
1867
+ // src/permissions/approval-bake.ts
1868
+ var APPROVAL_BAKE_PERMISSIONS = {
1869
+ actions: [],
1870
+ resources: []
1871
+ };
1872
+
1873
+ // src/permissions/approval-test.ts
1874
+ var APPROVAL_TEST_PERMISSIONS = {
1875
+ actions: [],
1876
+ resources: []
1877
+ };
1878
+
1879
+ // src/permissions/mirror-ecr.ts
1880
+ var MIRROR_ECR_PERMISSIONS = {
1881
+ actions: [
1882
+ // ECR authentication
1883
+ "ecr:GetAuthorizationToken",
1884
+ // Pull operations (source ECR)
1885
+ "ecr:BatchGetImage",
1886
+ "ecr:GetDownloadUrlForLayer",
1887
+ // Push operations (target ECR)
1888
+ "ecr:PutImage",
1889
+ "ecr:InitiateLayerUpload",
1890
+ "ecr:UploadLayerPart",
1891
+ "ecr:CompleteLayerUpload",
1892
+ "ecr:BatchCheckLayerAvailability"
1893
+ ],
1894
+ resources: ["*"]
1895
+ };
1896
+
1897
+ // src/permissions/mirror-s3.ts
1898
+ var MIRROR_S3_PERMISSIONS = {
1899
+ actions: [
1900
+ // Read operations (source bucket)
1901
+ "s3:GetObject",
1902
+ "s3:HeadObject",
1903
+ // Write operations (target bucket)
1904
+ "s3:PutObject",
1905
+ "s3:PutObjectAcl"
1906
+ ],
1907
+ resources: ["*"]
1908
+ };
1909
+
1910
+ // src/permissions/bundle-import.ts
1911
+ var BUNDLE_IMPORT_PERMISSIONS = {
1912
+ actions: [
1913
+ // Read operations (source bucket in CI/CD account)
1914
+ "s3:GetObject",
1915
+ "s3:HeadObject",
1916
+ // Write operations (target bucket in deployment account)
1917
+ "s3:PutObject",
1918
+ "s3:PutObjectAcl",
1919
+ // Cross-account role assumption
1920
+ "sts:AssumeRole"
1921
+ ],
1922
+ resources: ["*"]
1923
+ };
1924
+
1925
+ // src/permissions/docker-import.ts
1926
+ var DOCKER_IMPORT_PERMISSIONS = {
1927
+ actions: [
1928
+ // ECR authentication
1929
+ "ecr:GetAuthorizationToken",
1930
+ // Check image availability (source ECR)
1931
+ "ecr:DescribeImages",
1932
+ // Pull operations (source ECR)
1933
+ "ecr:BatchGetImage",
1934
+ "ecr:GetDownloadUrlForLayer",
1935
+ // Push operations (target ECR)
1936
+ "ecr:PutImage",
1937
+ "ecr:InitiateLayerUpload",
1938
+ "ecr:UploadLayerPart",
1939
+ "ecr:CompleteLayerUpload",
1940
+ "ecr:BatchCheckLayerAvailability",
1941
+ // Cross-account role assumption
1942
+ "sts:AssumeRole"
1943
+ ],
1944
+ resources: ["*"]
1945
+ };
1946
+
1947
+ // src/permissions/custom.ts
1948
+ function getCustomPermissions(stepType) {
1949
+ verbose(
1950
+ `Step type '${stepType}' is a custom step. Ensure permissions are defined in aws_additional_iam_policies.yaml/json`
1951
+ );
1952
+ return {
1953
+ actions: [],
1954
+ resources: []
1955
+ };
1956
+ }
1957
+
1958
+ // src/permissions/index.ts
1959
+ var PERMISSIONS_REGISTRY = {
1960
+ // Deployment steps
1961
+ "DEVRAMPS:EKS:DEPLOY": EKS_DEPLOY_PERMISSIONS,
1962
+ "DEVRAMPS:EKS:HELM": EKS_HELM_PERMISSIONS,
1963
+ "DEVRAMPS:ECS:DEPLOY": ECS_DEPLOY_PERMISSIONS,
1964
+ // Artifact mirroring steps (CI/CD account -> deployment account)
1965
+ "DEVRAMPS:MIRROR:ECR": MIRROR_ECR_PERMISSIONS,
1966
+ "DEVRAMPS:MIRROR:S3": MIRROR_S3_PERMISSIONS,
1967
+ // Artifact import steps (cross-account)
1968
+ "DEVRAMPS:BUNDLE:IMPORT": BUNDLE_IMPORT_PERMISSIONS,
1969
+ "DEVRAMPS:DOCKER:IMPORT": DOCKER_IMPORT_PERMISSIONS,
1970
+ // Approval/wait steps (no AWS permissions needed)
1971
+ "DEVRAMPS:APPROVAL:BAKE": APPROVAL_BAKE_PERMISSIONS,
1972
+ "DEVRAMPS:APPROVAL:TEST": APPROVAL_TEST_PERMISSIONS
1973
+ };
1974
+ function getStepPermissions(stepType) {
1975
+ const permissions = PERMISSIONS_REGISTRY[stepType];
1976
+ if (permissions) {
1977
+ return permissions;
1978
+ }
1979
+ if (stepType.startsWith("CUSTOM:")) {
1980
+ return getCustomPermissions(stepType);
1981
+ }
1982
+ return {
1983
+ actions: [],
1984
+ resources: []
1985
+ };
1986
+ }
1987
+ function hasPermissions(stepType) {
1988
+ const permissions = getStepPermissions(stepType);
1989
+ return permissions.actions.length > 0;
1990
+ }
1991
+
1992
+ // src/templates/stage-stack.ts
1993
+ function generateStageStackTemplate(options) {
1994
+ const {
1995
+ pipelineSlug,
1996
+ stageName,
1997
+ orgSlug,
1998
+ accountId,
1999
+ steps,
2000
+ additionalPolicies,
2001
+ dockerArtifacts,
2002
+ bundleArtifacts
2003
+ } = options;
2004
+ const template = createBaseTemplate(
2005
+ `DevRamps Stage Stack for ${pipelineSlug}/${stageName}`
2006
+ );
2007
+ addOidcProviderResource(template, true);
2008
+ const roleName = generateStageRoleName(pipelineSlug, stageName);
2009
+ const trustPolicy = buildStageTrustPolicy(accountId, orgSlug, pipelineSlug, stageName);
2010
+ const policies = buildStagePolicies(steps, additionalPolicies);
2011
+ template.Resources.StageDeploymentRole = createIamRoleResource(
2012
+ roleName,
2013
+ trustPolicy,
2014
+ policies.length > 0 ? policies : void 0,
2015
+ [
2016
+ { Key: "Pipeline", Value: pipelineSlug },
2017
+ { Key: "Stage", Value: stageName },
2018
+ { Key: "Organization", Value: orgSlug }
2019
+ ]
2020
+ );
2021
+ const ecrOutputs = {};
2022
+ const s3Outputs = {};
2023
+ for (const artifact of dockerArtifacts) {
2024
+ const artifactId = getArtifactId(artifact);
2025
+ const repoName = generateStageEcrRepoName(pipelineSlug, stageName, artifactId);
2026
+ const resourceId = sanitizeResourceId(`ECR${artifactId}`);
2027
+ template.Resources[resourceId] = createEcrRepositoryResource(
2028
+ repoName,
2029
+ [
2030
+ { Key: "Pipeline", Value: pipelineSlug },
2031
+ { Key: "Stage", Value: stageName },
2032
+ { Key: "Artifact", Value: artifact.name },
2033
+ { Key: "ArtifactType", Value: artifact.type }
2034
+ ]
2035
+ );
2036
+ ecrOutputs[artifact.name] = { resourceId };
2037
+ }
2038
+ for (const artifact of bundleArtifacts) {
2039
+ const artifactId = getArtifactId(artifact);
2040
+ const bucketName = generateStageBucketName(accountId, pipelineSlug, stageName, artifactId);
2041
+ const resourceId = sanitizeResourceId(`Bucket${artifactId}`);
2042
+ template.Resources[resourceId] = createS3BucketResource(
2043
+ bucketName,
2044
+ [
2045
+ { Key: "Pipeline", Value: pipelineSlug },
2046
+ { Key: "Stage", Value: stageName },
2047
+ { Key: "Artifact", Value: artifact.name },
2048
+ { Key: "ArtifactType", Value: artifact.type }
2049
+ ]
2050
+ );
2051
+ s3Outputs[artifact.name] = { resourceId };
2052
+ }
2053
+ template.Outputs = {
2054
+ StageRoleArn: {
2055
+ Description: "ARN of the stage deployment role",
2056
+ Value: { "Fn::GetAtt": ["StageDeploymentRole", "Arn"] },
2057
+ Export: { Name: `DevRamps-${pipelineSlug}-${stageName}-RoleArn` }
2058
+ },
2059
+ StageRoleName: {
2060
+ Description: "Name of the stage deployment role",
2061
+ Value: { Ref: "StageDeploymentRole" }
2062
+ },
2063
+ OIDCProviderArn: {
2064
+ Description: "ARN of the OIDC provider",
2065
+ Value: getOidcProviderArn(accountId, true)
2066
+ },
2067
+ PipelineSlug: {
2068
+ Description: "Pipeline slug",
2069
+ Value: pipelineSlug
2070
+ },
2071
+ StageName: {
2072
+ Description: "Stage name",
2073
+ Value: stageName
2074
+ }
2075
+ };
2076
+ for (const [artifactName, { resourceId }] of Object.entries(ecrOutputs)) {
2077
+ const safeName = sanitizeResourceId(artifactName);
2078
+ template.Outputs[`${safeName}RepoUri`] = {
2079
+ Description: `ECR Repository URI for ${artifactName}`,
2080
+ Value: { "Fn::GetAtt": [resourceId, "RepositoryUri"] }
2081
+ };
2082
+ }
2083
+ for (const [artifactName, { resourceId }] of Object.entries(s3Outputs)) {
2084
+ const safeName = sanitizeResourceId(artifactName);
2085
+ template.Outputs[`${safeName}BucketName`] = {
2086
+ Description: `S3 Bucket name for ${artifactName}`,
2087
+ Value: { Ref: resourceId }
2088
+ };
2089
+ }
2090
+ return template;
2091
+ }
2092
+ function buildStageTrustPolicy(accountId, orgSlug, pipelineSlug, stageName) {
2093
+ const subject = `org:${orgSlug}/pipeline:${pipelineSlug}/stage:${stageName}`;
2094
+ return buildOidcTrustPolicy(accountId, subject);
2095
+ }
2096
+ function buildStagePolicies(steps, additionalPolicies) {
2097
+ const policies = [];
2098
+ for (const step of steps) {
2099
+ if (!hasPermissions(step.type)) {
2100
+ continue;
2101
+ }
2102
+ const permissions = getStepPermissions(step.type);
2103
+ if (!permissions.actions || permissions.actions.length === 0) {
2104
+ continue;
2105
+ }
2106
+ const policyName = `${sanitizeResourceId(step.name)}DeploymentPolicy`;
2107
+ policies.push({
2108
+ PolicyName: policyName,
2109
+ PolicyDocument: {
2110
+ Version: "2012-10-17",
2111
+ Statement: [
2112
+ {
2113
+ Sid: sanitizeResourceId(step.name),
2114
+ Effect: "Allow",
2115
+ Action: permissions.actions,
2116
+ Resource: permissions.resources || ["*"]
2117
+ }
2118
+ ]
2119
+ }
2120
+ });
2121
+ }
2122
+ for (let i = 0; i < additionalPolicies.length; i++) {
2123
+ const policy = additionalPolicies[i];
2124
+ const policyName = `AdditionalPolicy${i + 1}`;
2125
+ policies.push({
2126
+ PolicyName: policyName,
2127
+ PolicyDocument: {
2128
+ Version: policy.Version || "2012-10-17",
2129
+ Statement: policy.Statement.map((stmt) => ({
2130
+ ...stmt.Sid ? { Sid: stmt.Sid } : {},
2131
+ Effect: stmt.Effect,
2132
+ Action: Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action],
2133
+ Resource: stmt.Resource,
2134
+ ...stmt.Condition ? { Condition: stmt.Condition } : {}
2135
+ }))
2136
+ }
2137
+ });
2138
+ }
2139
+ return policies;
2140
+ }
2141
+
2142
+ // src/merge/index.ts
2143
+ var MERGE_STRATEGIES = /* @__PURE__ */ new Map();
2144
+ var bucketPolicyStrategy = new BucketPolicyMergeStrategy();
2145
+ MERGE_STRATEGIES.set(bucketPolicyStrategy.strategyId, bucketPolicyStrategy);
2146
+ function getBucketPolicyStrategy() {
2147
+ return bucketPolicyStrategy;
2148
+ }
2149
+
2150
+ // src/utils/prompts.ts
2151
+ import inquirer from "inquirer";
2152
+ async function confirmDeployment(plan) {
2153
+ header("DevRamps Bootstrap Summary");
2154
+ console.log(`Organization: ${plan.orgSlug}`);
2155
+ newline();
2156
+ const pipelineGroups = /* @__PURE__ */ new Map();
2157
+ for (const stack of plan.stacks) {
2158
+ const existing = pipelineGroups.get(stack.pipelineSlug) || [];
2159
+ existing.push(stack);
2160
+ pipelineGroups.set(stack.pipelineSlug, existing);
2161
+ }
2162
+ console.log("Pipelines to bootstrap:");
2163
+ for (const [slug, stacks] of pipelineGroups) {
2164
+ const accounts = new Set(stacks.map((s) => s.accountId));
2165
+ console.log(` - ${slug} (${accounts.size} target account${accounts.size !== 1 ? "s" : ""})`);
2166
+ }
2167
+ newline();
2168
+ console.log("Stacks to deploy:");
2169
+ const tableRows = [
2170
+ ["Account ID", "Pipeline", "Stack Name", "Action"]
2171
+ ];
2172
+ for (const stack of plan.stacks) {
2173
+ tableRows.push([
2174
+ stack.accountId,
2175
+ stack.pipelineSlug,
2176
+ stack.stackName,
2177
+ stack.action
2178
+ ]);
2179
+ }
2180
+ table(tableRows);
2181
+ newline();
2182
+ console.log("Each stack creates:");
2183
+ console.log(" - OIDC Identity Provider for devramps.com (if not exists)");
2184
+ console.log(" - IAM Role: DevRamps-CICD-DeploymentRole");
2185
+ console.log(` - Trust: org:${plan.orgSlug}/pipeline:<pipeline-slug>`);
2186
+ console.log(" - Policies for each deployment step");
2187
+ newline();
2188
+ const { proceed } = await inquirer.prompt([
2189
+ {
2190
+ type: "confirm",
2191
+ name: "proceed",
2192
+ message: "Do you want to proceed?",
2193
+ default: false
2194
+ }
2195
+ ]);
2196
+ return proceed;
2197
+ }
2198
+
2199
+ // src/commands/bootstrap.ts
2200
+ async function bootstrapCommand(options) {
2201
+ try {
2202
+ if (options.verbose) {
2203
+ setVerbose(true);
2204
+ }
2205
+ header("DevRamps Bootstrap");
2206
+ const spinner = ora("Checking AWS credentials...").start();
2207
+ const identity = await getCurrentIdentity();
2208
+ spinner.succeed(`Authenticated as ${identity.arn}`);
2209
+ const authData = await authenticateViaBrowser({
2210
+ endpointOverride: options.endpointOverride
2211
+ });
2212
+ spinner.start("Finding pipelines...");
2213
+ const basePath = process.cwd();
2214
+ const filterSlugs = options.pipelineSlugs ? options.pipelineSlugs.split(",").map((s) => s.trim()) : void 0;
2215
+ const pipelineSlugs = await findDevrampsPipelines(basePath, filterSlugs);
2216
+ if (pipelineSlugs.length === 0) {
2217
+ spinner.fail("No pipelines found");
2218
+ error("No pipeline.yaml files found in .devramps/ folder.");
2219
+ process.exit(1);
2220
+ }
2221
+ spinner.text = `Parsing ${pipelineSlugs.length} pipeline(s)...`;
2222
+ const pipelines = [];
2223
+ const pipelineArtifacts = /* @__PURE__ */ new Map();
2224
+ for (const slug of pipelineSlugs) {
2225
+ const pipeline = await parsePipeline(basePath, slug);
2226
+ pipelines.push(pipeline);
2227
+ const artifacts = parseArtifacts(pipeline.definition);
2228
+ pipelineArtifacts.set(slug, artifacts);
2229
+ }
2230
+ spinner.succeed(`Found ${pipelines.length} pipeline(s)`);
2231
+ spinner.start("Building deployment plan...");
2232
+ const plan = await buildDeploymentPlan(
2233
+ pipelines,
2234
+ pipelineArtifacts,
2235
+ authData,
2236
+ identity.accountId,
2237
+ options.targetAccountRoleName
2238
+ );
2239
+ spinner.succeed("Deployment plan ready");
2240
+ if (options.dryRun) {
2241
+ await showDryRunPlan(plan);
2242
+ return;
2243
+ }
2244
+ const confirmed = await confirmDeploymentPlan(plan);
2245
+ if (!confirmed) {
2246
+ info("Deployment cancelled by user.");
2247
+ return;
2248
+ }
2249
+ await executeDeployment(plan, pipelines, pipelineArtifacts, authData, identity.accountId, options);
2250
+ } catch (error2) {
2251
+ if (error2 instanceof DevRampsError) {
2252
+ error(error2.message);
2253
+ process.exit(1);
2254
+ }
2255
+ error(`Unexpected error: ${error2 instanceof Error ? error2.message : String(error2)}`);
2256
+ if (isVerbose() && error2 instanceof Error && error2.stack) {
2257
+ console.error(error2.stack);
2258
+ }
2259
+ process.exit(1);
2260
+ }
2261
+ }
2262
+ async function buildDeploymentPlan(pipelines, pipelineArtifacts, authData, currentAccountId, targetRoleName) {
2263
+ const { orgSlug, cicdAccountId, cicdRegion } = authData;
2264
+ const allTargetAccountIds = /* @__PURE__ */ new Set();
2265
+ for (const pipeline of pipelines) {
2266
+ for (const accountId of pipeline.targetAccountIds) {
2267
+ allTargetAccountIds.add(accountId);
2268
+ }
2269
+ }
2270
+ let cicdCredentials;
2271
+ try {
2272
+ if (cicdAccountId !== currentAccountId) {
2273
+ const assumed = await assumeRoleForAccount({
2274
+ targetAccountId: cicdAccountId,
2275
+ currentAccountId,
2276
+ targetRoleName
2277
+ });
2278
+ cicdCredentials = assumed?.credentials;
2279
+ }
2280
+ } catch {
2281
+ verbose("Could not assume role in CI/CD account for status check");
2282
+ }
2283
+ const orgStackName = getOrgStackName(orgSlug);
2284
+ const orgStack = {
2285
+ stackType: "Org",
2286
+ stackName: orgStackName,
2287
+ accountId: cicdAccountId,
2288
+ region: cicdRegion,
2289
+ action: await determineStackAction(orgStackName, cicdCredentials, cicdRegion),
2290
+ orgSlug,
2291
+ targetAccountIds: Array.from(allTargetAccountIds)
2292
+ };
2293
+ const pipelineStacks = [];
2294
+ for (const pipeline of pipelines) {
2295
+ const artifacts = pipelineArtifacts.get(pipeline.slug);
2296
+ const filteredArtifacts = filterArtifactsForPipelineStack(artifacts);
2297
+ const stackName = getPipelineStackName(pipeline.slug);
2298
+ pipelineStacks.push({
2299
+ stackType: "Pipeline",
2300
+ stackName,
2301
+ accountId: cicdAccountId,
2302
+ region: cicdRegion,
2303
+ action: await determineStackAction(stackName, cicdCredentials, cicdRegion),
2304
+ pipelineSlug: pipeline.slug,
2305
+ dockerArtifacts: filteredArtifacts.docker,
2306
+ bundleArtifacts: filteredArtifacts.bundle
2307
+ });
2308
+ }
2309
+ const stageStacks = [];
2310
+ for (const pipeline of pipelines) {
2311
+ const artifacts = pipelineArtifacts.get(pipeline.slug);
2312
+ for (const stage of pipeline.stages) {
2313
+ const stackName = getStageStackName(pipeline.slug, stage.name);
2314
+ let stageCredentials;
2315
+ try {
2316
+ if (stage.account_id !== currentAccountId) {
2317
+ const assumed = await assumeRoleForAccount({
2318
+ targetAccountId: stage.account_id,
2319
+ currentAccountId,
2320
+ targetRoleName
2321
+ });
2322
+ stageCredentials = assumed?.credentials;
2323
+ }
2324
+ } catch {
2325
+ verbose(`Could not assume role in ${stage.account_id} for status check`);
2326
+ }
2327
+ stageStacks.push({
2328
+ stackType: "Stage",
2329
+ stackName,
2330
+ accountId: stage.account_id,
2331
+ region: stage.region,
2332
+ action: await determineStackAction(stackName, stageCredentials, stage.region),
2333
+ pipelineSlug: pipeline.slug,
2334
+ stageName: stage.name,
2335
+ orgSlug,
2336
+ steps: pipeline.steps,
2337
+ additionalPolicies: pipeline.additionalPolicies,
2338
+ dockerArtifacts: artifacts.docker,
2339
+ bundleArtifacts: artifacts.bundle
2340
+ });
2341
+ }
2342
+ }
2343
+ return {
2344
+ orgSlug,
2345
+ cicdAccountId,
2346
+ cicdRegion,
2347
+ orgStack,
2348
+ pipelineStacks,
2349
+ stageStacks
2350
+ };
2351
+ }
2352
+ async function determineStackAction(stackName, credentials, region) {
2353
+ try {
2354
+ const status = await getStackStatus(stackName, credentials, region);
2355
+ return status.exists ? "UPDATE" : "CREATE";
2356
+ } catch {
2357
+ return "CREATE";
2358
+ }
2359
+ }
2360
+ async function showDryRunPlan(plan) {
2361
+ newline();
2362
+ header("Deployment Plan (Dry Run)");
2363
+ info(`Organization: ${plan.orgSlug}`);
2364
+ info(`CI/CD Account: ${plan.cicdAccountId}`);
2365
+ info(`CI/CD Region: ${plan.cicdRegion}`);
2366
+ newline();
2367
+ info("Phase 1: Org Stack");
2368
+ info(` ${plan.orgStack.action}: ${plan.orgStack.stackName}`);
2369
+ info(` Account: ${plan.orgStack.accountId}`);
2370
+ info(` Target accounts with bucket access: ${plan.orgStack.targetAccountIds.length}`);
2371
+ newline();
2372
+ info("Phase 2: Pipeline Stacks");
2373
+ for (const stack of plan.pipelineStacks) {
2374
+ info(` ${stack.action}: ${stack.stackName}`);
2375
+ info(` ECR repos: ${stack.dockerArtifacts.length}, S3 buckets: ${stack.bundleArtifacts.length}`);
2376
+ }
2377
+ newline();
2378
+ info("Phase 3: Stage Stacks");
2379
+ for (const stack of plan.stageStacks) {
2380
+ info(` ${stack.action}: ${stack.stackName}`);
2381
+ info(` Account: ${stack.accountId}, Region: ${stack.region}`);
2382
+ info(` ECR repos: ${stack.dockerArtifacts.length}, S3 buckets: ${stack.bundleArtifacts.length}`);
2383
+ }
2384
+ const totalStacks = 1 + plan.pipelineStacks.length + plan.stageStacks.length;
2385
+ newline();
2386
+ info(`Total stacks to deploy: ${totalStacks}`);
2387
+ }
2388
+ async function confirmDeploymentPlan(plan) {
2389
+ const totalStacks = 1 + plan.pipelineStacks.length + plan.stageStacks.length;
2390
+ newline();
2391
+ info(`About to deploy ${totalStacks} stack(s):`);
2392
+ info(` - 1 Org stack (${plan.orgStack.action})`);
2393
+ info(` - ${plan.pipelineStacks.length} Pipeline stack(s)`);
2394
+ info(` - ${plan.stageStacks.length} Stage stack(s)`);
2395
+ return confirmDeployment({
2396
+ orgSlug: plan.orgSlug,
2397
+ stacks: [
2398
+ { ...plan.orgStack, pipelineSlug: "org", steps: [], additionalPoliciesCount: 0 },
2399
+ ...plan.pipelineStacks.map((s) => ({ ...s, steps: [], additionalPoliciesCount: 0 })),
2400
+ ...plan.stageStacks.map((s) => ({ ...s, steps: s.steps.map((st) => st.name), additionalPoliciesCount: s.additionalPolicies.length }))
2401
+ ]
2402
+ });
2403
+ }
2404
+ async function executeDeployment(plan, pipelines, pipelineArtifacts, authData, currentAccountId, options) {
2405
+ const results = { success: 0, failed: 0 };
2406
+ newline();
2407
+ header("Phase 1: Org Stack");
2408
+ try {
2409
+ await deployOrgStack(plan, pipelines, authData, currentAccountId, options);
2410
+ results.success++;
2411
+ success("Org stack deployed successfully");
2412
+ } catch (error2) {
2413
+ results.failed++;
2414
+ error(`Org stack failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
2415
+ throw error2;
2416
+ }
2417
+ newline();
2418
+ header("Phase 2: Pipeline Stacks");
2419
+ for (const stack of plan.pipelineStacks) {
2420
+ const spinner = ora(`Deploying ${stack.stackName}...`).start();
2421
+ try {
2422
+ await deployPipelineStack(stack, authData, currentAccountId, options);
2423
+ spinner.succeed(`${stack.stackName} deployed`);
2424
+ results.success++;
2425
+ } catch (error2) {
2426
+ spinner.fail(`${stack.stackName} failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
2427
+ results.failed++;
2428
+ }
2429
+ }
2430
+ newline();
2431
+ header("Phase 3: Stage Stacks");
2432
+ for (const stack of plan.stageStacks) {
2433
+ const spinner = ora(`Deploying ${stack.stackName} to ${stack.accountId}/${stack.region}...`).start();
2434
+ try {
2435
+ await deployStageStack(stack, authData, currentAccountId, options);
2436
+ spinner.succeed(`${stack.stackName} deployed to ${stack.accountId}`);
2437
+ results.success++;
2438
+ } catch (error2) {
2439
+ spinner.fail(`${stack.stackName} failed: ${error2 instanceof Error ? error2.message : String(error2)}`);
2440
+ results.failed++;
2441
+ }
2442
+ }
2443
+ newline();
2444
+ header("Deployment Summary");
2445
+ if (results.failed === 0) {
2446
+ success(`All ${results.success} stack(s) deployed successfully!`);
2447
+ } else {
2448
+ warn(`${results.success} stack(s) succeeded, ${results.failed} stack(s) failed.`);
2449
+ }
2450
+ }
2451
+ async function deployOrgStack(plan, pipelines, authData, currentAccountId, options) {
2452
+ const { orgSlug, cicdAccountId, cicdRegion } = authData;
2453
+ const credentials = cicdAccountId !== currentAccountId ? (await assumeRoleForAccount({
2454
+ targetAccountId: cicdAccountId,
2455
+ currentAccountId,
2456
+ targetRoleName: options.targetAccountRoleName
2457
+ }))?.credentials : void 0;
2458
+ let targetAccountIds = plan.orgStack.targetAccountIds;
2459
+ if (plan.orgStack.action === "UPDATE") {
2460
+ verbose("Merging bucket policy with existing accounts...");
2461
+ const bucketName = generateTerraformStateBucketName(orgSlug);
2462
+ const strategy = getBucketPolicyStrategy();
2463
+ strategy.configure(bucketName, cicdRegion, credentials);
2464
+ const existingStack = await readExistingStack(
2465
+ plan.orgStack.stackName,
2466
+ cicdAccountId,
2467
+ cicdRegion,
2468
+ credentials
2469
+ );
2470
+ if (existingStack) {
2471
+ const existing = await strategy.extractExisting(existingStack);
2472
+ const newData = { allowedAccountIds: targetAccountIds };
2473
+ const merged = strategy.merge(existing, newData);
2474
+ targetAccountIds = merged.allowedAccountIds;
2475
+ verbose(`Merged ${targetAccountIds.length} account(s) into bucket policy`);
2476
+ }
2477
+ }
2478
+ const template = generateOrgStackTemplate({
2479
+ orgSlug,
2480
+ cicdAccountId,
2481
+ targetAccountIds
2482
+ });
2483
+ const deployOptions = {
2484
+ stackName: plan.orgStack.stackName,
2485
+ template,
2486
+ accountId: cicdAccountId,
2487
+ region: cicdRegion,
2488
+ credentials
2489
+ };
2490
+ await previewStackChanges(deployOptions);
2491
+ await deployStack(deployOptions);
2492
+ }
2493
+ async function deployPipelineStack(stack, authData, currentAccountId, options) {
2494
+ const { cicdAccountId, cicdRegion } = authData;
2495
+ const credentials = cicdAccountId !== currentAccountId ? (await assumeRoleForAccount({
2496
+ targetAccountId: cicdAccountId,
2497
+ currentAccountId,
2498
+ targetRoleName: options.targetAccountRoleName
2499
+ }))?.credentials : void 0;
2500
+ const template = generatePipelineStackTemplate({
2501
+ pipelineSlug: stack.pipelineSlug,
2502
+ cicdAccountId,
2503
+ dockerArtifacts: stack.dockerArtifacts,
2504
+ bundleArtifacts: stack.bundleArtifacts
2505
+ });
2506
+ const deployOptions = {
2507
+ stackName: stack.stackName,
2508
+ template,
2509
+ accountId: cicdAccountId,
2510
+ region: cicdRegion,
2511
+ credentials
2512
+ };
2513
+ await previewStackChanges(deployOptions);
2514
+ await deployStack(deployOptions);
2515
+ }
2516
+ async function deployStageStack(stack, authData, currentAccountId, options) {
2517
+ const credentials = stack.accountId !== currentAccountId ? (await assumeRoleForAccount({
2518
+ targetAccountId: stack.accountId,
2519
+ currentAccountId,
2520
+ targetRoleName: options.targetAccountRoleName
2521
+ }))?.credentials : void 0;
2522
+ const oidcInfo = await checkOidcProviderExists(credentials, stack.region);
2523
+ verbose(`OIDC provider in ${stack.accountId}: ${oidcInfo.exists ? "exists" : "will be created"}`);
2524
+ const template = generateStageStackTemplate({
2525
+ pipelineSlug: stack.pipelineSlug,
2526
+ stageName: stack.stageName,
2527
+ orgSlug: stack.orgSlug,
2528
+ accountId: stack.accountId,
2529
+ steps: stack.steps,
2530
+ additionalPolicies: stack.additionalPolicies,
2531
+ dockerArtifacts: stack.dockerArtifacts,
2532
+ bundleArtifacts: stack.bundleArtifacts
2533
+ });
2534
+ const deployOptions = {
2535
+ stackName: stack.stackName,
2536
+ template,
2537
+ accountId: stack.accountId,
2538
+ region: stack.region,
2539
+ credentials
2540
+ };
2541
+ await previewStackChanges(deployOptions);
2542
+ await deployStack(deployOptions);
2543
+ }
2544
+
2545
+ // src/index.ts
2546
+ program.name("devramps").description("DevRamps CLI - Bootstrap AWS infrastructure for CI/CD pipelines").version("0.1.0");
2547
+ program.command("bootstrap").description("Bootstrap IAM roles in target AWS accounts based on pipeline definitions").option(
2548
+ "--target-account-role-name <name>",
2549
+ "Role to assume in target accounts (default: OrganizationAccountAccessRole, fallback: AWSControlTowerExecution)"
2550
+ ).option(
2551
+ "--pipeline-slugs <slugs>",
2552
+ "Comma-separated list of pipeline slugs to bootstrap (default: all pipelines)"
2553
+ ).option(
2554
+ "--dry-run",
2555
+ "Show what would be deployed without actually deploying"
2556
+ ).option(
2557
+ "--verbose",
2558
+ "Enable verbose logging for debugging"
2559
+ ).option(
2560
+ "--endpoint-override <url>",
2561
+ "Override the DevRamps API endpoint (for testing, e.g., http://localhost:3000)"
2562
+ ).action(bootstrapCommand);
2563
+ program.parse();