@pagopa/dx-cli 0.16.3 → 0.18.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.
Files changed (40) hide show
  1. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +35 -0
  2. package/dist/adapters/azure/cloud-account-service.d.ts +2 -1
  3. package/dist/adapters/azure/cloud-account-service.js +74 -14
  4. package/dist/adapters/commander/commands/add.d.ts +11 -0
  5. package/dist/adapters/commander/commands/add.js +27 -0
  6. package/dist/adapters/commander/commands/init.d.ts +2 -0
  7. package/dist/adapters/commander/commands/init.js +1 -1
  8. package/dist/adapters/commander/index.js +2 -0
  9. package/dist/adapters/octokit/__tests__/index.test.js +218 -1
  10. package/dist/adapters/octokit/index.d.ts +4 -1
  11. package/dist/adapters/octokit/index.js +65 -1
  12. package/dist/adapters/pagopa-technology/__tests__/authorization.test.d.ts +4 -0
  13. package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +170 -0
  14. package/dist/adapters/pagopa-technology/authorization.d.ts +11 -0
  15. package/dist/adapters/pagopa-technology/authorization.js +104 -0
  16. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +35 -3
  17. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +15 -0
  18. package/dist/adapters/plop/actions/init-cloud-accounts.js +5 -2
  19. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +5 -0
  20. package/dist/adapters/plop/generators/environment/prompts.d.ts +14 -0
  21. package/dist/adapters/plop/generators/environment/prompts.js +67 -31
  22. package/dist/adapters/plop/index.d.ts +18 -2
  23. package/dist/adapters/plop/index.js +16 -0
  24. package/dist/domain/__tests__/data.d.ts +2 -0
  25. package/dist/domain/__tests__/data.js +1 -0
  26. package/dist/domain/authorization.d.ts +49 -0
  27. package/dist/domain/authorization.js +73 -0
  28. package/dist/domain/cloud-account.d.ts +2 -1
  29. package/dist/domain/dependencies.d.ts +2 -0
  30. package/dist/domain/github.d.ts +51 -0
  31. package/dist/domain/github.js +12 -0
  32. package/dist/index.js +5 -0
  33. package/dist/use-cases/__tests__/request-authorization.test.d.ts +4 -0
  34. package/dist/use-cases/__tests__/request-authorization.test.js +40 -0
  35. package/dist/use-cases/request-authorization.d.ts +15 -0
  36. package/dist/use-cases/request-authorization.js +13 -0
  37. package/package.json +3 -1
  38. package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +2 -0
  39. package/templates/environment/core/{{env.name}}/imports.tf.hbs +34 -0
  40. package/templates/environment/core/{{env.name}}/providers.tf.hbs +4 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests for the PagoPA technology authorization adapter.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { mock } from "vitest-mock-extended";
6
+ import { AuthorizationResult, IdentityAlreadyExistsError, InvalidAuthorizationFileFormatError, requestAuthorizationInputSchema, } from "../../../domain/authorization.js";
7
+ import { FileNotFoundError, PullRequest, } from "../../../domain/github.js";
8
+ import { makeAuthorizationService } from "../authorization.js";
9
+ const makeEnv = () => {
10
+ const gitHubService = mock();
11
+ const authorizationService = makeAuthorizationService(gitHubService);
12
+ return {
13
+ authorizationService,
14
+ gitHubService,
15
+ };
16
+ };
17
+ const makeSampleInput = () => requestAuthorizationInputSchema.parse({
18
+ bootstrapIdentityId: "test-bootstrap-identity-id",
19
+ subscriptionName: "test-subscription",
20
+ });
21
+ // eslint-disable-next-line max-lines-per-function
22
+ describe("PagoPA AuthorizationService", () => {
23
+ describe("happy path", () => {
24
+ it("should create a pull request when all steps succeed", async () => {
25
+ const { authorizationService, gitHubService } = makeEnv();
26
+ const input = makeSampleInput();
27
+ const originalContent = `
28
+ directory_readers = {
29
+ service_principals_name = []
30
+ }
31
+ `.trim();
32
+ gitHubService.createBranch.mockResolvedValue(undefined);
33
+ gitHubService.getFileContent.mockResolvedValue({
34
+ content: originalContent,
35
+ sha: "original-sha-123",
36
+ });
37
+ gitHubService.updateFile.mockResolvedValue(undefined);
38
+ gitHubService.createPullRequest.mockResolvedValue(new PullRequest("https://github.com/pagopa/eng-azure-authorization/pull/42"));
39
+ const result = await authorizationService.requestAuthorization(input);
40
+ expect(result.isOk()).toBe(true);
41
+ const authResult = result._unsafeUnwrap();
42
+ expect(authResult).toBeInstanceOf(AuthorizationResult);
43
+ expect(authResult.url).toBe("https://github.com/pagopa/eng-azure-authorization/pull/42");
44
+ expect(gitHubService.createBranch).toHaveBeenCalledWith({
45
+ branchName: "feats/add-test-subscription-bootstrap-identity",
46
+ fromRef: "main",
47
+ owner: "pagopa",
48
+ repo: "eng-azure-authorization",
49
+ });
50
+ expect(gitHubService.getFileContent).toHaveBeenCalledWith({
51
+ owner: "pagopa",
52
+ path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
53
+ ref: "feats/add-test-subscription-bootstrap-identity",
54
+ repo: "eng-azure-authorization",
55
+ });
56
+ expect(gitHubService.updateFile).toHaveBeenCalledWith(expect.objectContaining({
57
+ branch: "feats/add-test-subscription-bootstrap-identity",
58
+ message: "Add directory reader for test-subscription",
59
+ owner: "pagopa",
60
+ path: "src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars",
61
+ repo: "eng-azure-authorization",
62
+ sha: "original-sha-123",
63
+ }));
64
+ expect(gitHubService.createPullRequest).toHaveBeenCalledWith({
65
+ base: "main",
66
+ body: "This PR adds the bootstrap identity `test-bootstrap-identity-id` to the directory readers for subscription `test-subscription`.",
67
+ head: "feats/add-test-subscription-bootstrap-identity",
68
+ owner: "pagopa",
69
+ repo: "eng-azure-authorization",
70
+ title: "Add directory reader for test-subscription",
71
+ });
72
+ });
73
+ });
74
+ describe("error handling", () => {
75
+ it("should return error when file is not found", async () => {
76
+ const { authorizationService, gitHubService } = makeEnv();
77
+ const input = makeSampleInput();
78
+ gitHubService.createBranch.mockResolvedValue(undefined);
79
+ gitHubService.getFileContent.mockRejectedValue(new FileNotFoundError("src/azure-subscriptions/subscriptions/test-subscription/terraform.tfvars"));
80
+ const result = await authorizationService.requestAuthorization(input);
81
+ expect(result.isErr()).toBe(true);
82
+ const error = result._unsafeUnwrapErr();
83
+ expect(error.message).toContain("Unable to get");
84
+ expect(error.message).toContain("test-subscription/terraform.tfvars");
85
+ expect(gitHubService.updateFile).not.toHaveBeenCalled();
86
+ });
87
+ it("should return error when identity already exists", async () => {
88
+ const { authorizationService, gitHubService } = makeEnv();
89
+ const input = makeSampleInput();
90
+ const content = `
91
+ directory_readers = {
92
+ service_principals_name = [
93
+ "test-bootstrap-identity-id"
94
+ ]
95
+ }
96
+ `.trim();
97
+ gitHubService.createBranch.mockResolvedValue(undefined);
98
+ gitHubService.getFileContent.mockResolvedValue({
99
+ content,
100
+ sha: "sha-123",
101
+ });
102
+ const result = await authorizationService.requestAuthorization(input);
103
+ expect(result.isErr()).toBe(true);
104
+ expect(result._unsafeUnwrapErr()).toBeInstanceOf(IdentityAlreadyExistsError);
105
+ expect(gitHubService.updateFile).not.toHaveBeenCalled();
106
+ });
107
+ it("should return error when tfvars format is invalid", async () => {
108
+ const { authorizationService, gitHubService } = makeEnv();
109
+ const input = makeSampleInput();
110
+ const invalidContent = "invalid content without directory_readers";
111
+ gitHubService.createBranch.mockResolvedValue(undefined);
112
+ gitHubService.getFileContent.mockResolvedValue({
113
+ content: invalidContent,
114
+ sha: "sha-123",
115
+ });
116
+ const result = await authorizationService.requestAuthorization(input);
117
+ expect(result.isErr()).toBe(true);
118
+ expect(result._unsafeUnwrapErr()).toBeInstanceOf(InvalidAuthorizationFileFormatError);
119
+ expect(gitHubService.updateFile).not.toHaveBeenCalled();
120
+ });
121
+ it("should return error when branch creation fails", async () => {
122
+ const { authorizationService, gitHubService } = makeEnv();
123
+ const input = makeSampleInput();
124
+ gitHubService.createBranch.mockRejectedValue(new Error("Failed to create branch: branch already exists"));
125
+ const result = await authorizationService.requestAuthorization(input);
126
+ expect(result.isErr()).toBe(true);
127
+ expect(result._unsafeUnwrapErr().message).toContain("Unable to create branch");
128
+ expect(gitHubService.getFileContent).not.toHaveBeenCalled();
129
+ expect(gitHubService.updateFile).not.toHaveBeenCalled();
130
+ });
131
+ it("should return error when file update fails", async () => {
132
+ const { authorizationService, gitHubService } = makeEnv();
133
+ const input = makeSampleInput();
134
+ const content = `
135
+ directory_readers = {
136
+ service_principals_name = []
137
+ }
138
+ `.trim();
139
+ gitHubService.createBranch.mockResolvedValue(undefined);
140
+ gitHubService.getFileContent.mockResolvedValue({
141
+ content,
142
+ sha: "sha-123",
143
+ });
144
+ gitHubService.updateFile.mockRejectedValue(new Error("Failed to update file: conflict"));
145
+ const result = await authorizationService.requestAuthorization(input);
146
+ expect(result.isErr()).toBe(true);
147
+ expect(result._unsafeUnwrapErr().message).toContain("Unable to update");
148
+ expect(gitHubService.createPullRequest).not.toHaveBeenCalled();
149
+ });
150
+ it("should return error when PR creation fails", async () => {
151
+ const { authorizationService, gitHubService } = makeEnv();
152
+ const input = makeSampleInput();
153
+ const content = `
154
+ directory_readers = {
155
+ service_principals_name = []
156
+ }
157
+ `.trim();
158
+ gitHubService.createBranch.mockResolvedValue(undefined);
159
+ gitHubService.getFileContent.mockResolvedValue({
160
+ content,
161
+ sha: "sha-123",
162
+ });
163
+ gitHubService.updateFile.mockResolvedValue(undefined);
164
+ gitHubService.createPullRequest.mockRejectedValue(new Error("Failed to create pull request"));
165
+ const result = await authorizationService.requestAuthorization(input);
166
+ expect(result.isErr()).toBe(true);
167
+ expect(result._unsafeUnwrapErr().message).toContain("Unable to create pull request");
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PagoPA Technology Authorization Adapter
3
+ *
4
+ * Implements the AuthorizationService interface for the PagoPA Azure
5
+ * authorization workflow. Encapsulates all platform-specific details:
6
+ * the target GitHub repository, file paths, branch naming, HCL file
7
+ * parsing, and pull request creation.
8
+ */
9
+ import { AuthorizationService } from "../../domain/authorization.js";
10
+ import { GitHubService } from "../../domain/github.js";
11
+ export declare const makeAuthorizationService: (gitHubService: GitHubService) => AuthorizationService;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * PagoPA Technology Authorization Adapter
3
+ *
4
+ * Implements the AuthorizationService interface for the PagoPA Azure
5
+ * authorization workflow. Encapsulates all platform-specific details:
6
+ * the target GitHub repository, file paths, branch naming, HCL file
7
+ * parsing, and pull request creation.
8
+ */
9
+ import { getLogger } from "@logtape/logtape";
10
+ import { err, errAsync, ok, okAsync, ResultAsync } from "neverthrow";
11
+ import { AuthorizationError, AuthorizationResult, IdentityAlreadyExistsError, InvalidAuthorizationFileFormatError, } from "../../domain/authorization.js";
12
+ // Matches the service_principals_name list inside the directory_readers block.
13
+ const DIRECTORY_READERS_REGEX = /(directory_readers\s*=\s*\{[\s\S]*?service_principals_name\s*=\s*\[)([\s\S]*?)(][\s\S]*?})/;
14
+ const addIdentity = (content, identityId) => {
15
+ const match = content.match(DIRECTORY_READERS_REGEX);
16
+ if (!match) {
17
+ return err(new InvalidAuthorizationFileFormatError("Could not find directory_readers.service_principals_name list"));
18
+ }
19
+ const [, prefix, existingItems, suffix] = match;
20
+ if (existingItems.includes(`"${identityId}"`)) {
21
+ return err(new IdentityAlreadyExistsError(identityId));
22
+ }
23
+ // Build the new list content following HCL formatting rules:
24
+ // - Items are indented with 4 spaces; the LAST item must NOT have a trailing comma
25
+ const newListContent = existingItems.trim().length > 0
26
+ ? `${existingItems.replace(/,?\s*$/, "")},\n "${identityId}"\n `
27
+ : `\n "${identityId}"\n `;
28
+ return ok(content.replace(DIRECTORY_READERS_REGEX, `${prefix}${newListContent}${suffix}`));
29
+ };
30
+ const REPO_OWNER = "pagopa";
31
+ const REPO_NAME = "eng-azure-authorization";
32
+ const BASE_BRANCH = "main";
33
+ export const makeAuthorizationService = (gitHubService) => ({
34
+ requestAuthorization(input) {
35
+ const logger = getLogger(["dx-cli", "pagopa-authorization"]);
36
+ const { bootstrapIdentityId, subscriptionName } = input;
37
+ const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars`;
38
+ const branchName = `feats/add-${subscriptionName}-bootstrap-identity`;
39
+ return (
40
+ // Step 1: Create branch first to avoid race condition with main branch updates
41
+ ResultAsync.fromPromise(gitHubService.createBranch({
42
+ branchName,
43
+ fromRef: BASE_BRANCH,
44
+ owner: REPO_OWNER,
45
+ repo: REPO_NAME,
46
+ }), () => new AuthorizationError(`Unable to create branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`))
47
+ .orTee((error) => {
48
+ logger.error(error.message);
49
+ })
50
+ // Step 2: Fetch file content from the newly created branch
51
+ .andThen(() => ResultAsync.fromPromise(gitHubService.getFileContent({
52
+ owner: REPO_OWNER,
53
+ path: filePath,
54
+ ref: branchName,
55
+ repo: REPO_NAME,
56
+ }), () => new AuthorizationError(`Unable to get ${filePath} in ${REPO_OWNER}/${REPO_NAME}`)))
57
+ .orTee((error) => {
58
+ logger.error(error.message);
59
+ })
60
+ // Modify the file content, detecting duplicates and format errors
61
+ .andThen(({ content, sha }) => addIdentity(content, bootstrapIdentityId)
62
+ .mapErr((error) => {
63
+ if (error instanceof IdentityAlreadyExistsError) {
64
+ logger.warn("Identity already exists", {
65
+ identityId: bootstrapIdentityId,
66
+ subscription: subscriptionName,
67
+ });
68
+ }
69
+ else {
70
+ logger.error("Failed to modify tfvars", {
71
+ error: error.message,
72
+ });
73
+ }
74
+ return error;
75
+ })
76
+ .match((updatedContent) => okAsync({ sha, updatedContent }), (error) => errAsync(error)))
77
+ // Update the file on the new branch
78
+ .andThen(({ sha, updatedContent }) => ResultAsync.fromPromise(gitHubService.updateFile({
79
+ branch: branchName,
80
+ content: updatedContent,
81
+ message: `Add directory reader for ${subscriptionName}`,
82
+ owner: REPO_OWNER,
83
+ path: filePath,
84
+ repo: REPO_NAME,
85
+ sha,
86
+ }), () => new AuthorizationError(`Unable to update ${filePath} on branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`)))
87
+ .orTee((error) => {
88
+ logger.error(error.message);
89
+ })
90
+ // Create a pull request for review
91
+ .andThen(() => ResultAsync.fromPromise(gitHubService.createPullRequest({
92
+ base: BASE_BRANCH,
93
+ body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers for subscription \`${subscriptionName}\`.`,
94
+ head: branchName,
95
+ owner: REPO_OWNER,
96
+ repo: REPO_NAME,
97
+ title: `Add directory reader for ${subscriptionName}`,
98
+ }), () => new AuthorizationError(`Unable to create pull request from ${branchName} to ${BASE_BRANCH} in ${REPO_OWNER}/${REPO_NAME}`)))
99
+ .orTee((error) => {
100
+ logger.error(error.message);
101
+ })
102
+ .map((pr) => new AuthorizationResult(pr.url)));
103
+ },
104
+ });
@@ -27,6 +27,11 @@ const createMockPayload = (overrides = {}) => ({
27
27
  },
28
28
  init: {
29
29
  cloudAccountsToInitialize: [],
30
+ runnerAppCredentials: {
31
+ id: "test-app-id",
32
+ installationId: "test-installation-id",
33
+ key: "test-private-key",
34
+ },
30
35
  terraformBackend: {
31
36
  cloudAccount: createMockCloudAccount(),
32
37
  },
@@ -53,6 +58,11 @@ describe("initCloudAccounts", () => {
53
58
  },
54
59
  init: {
55
60
  cloudAccountsToInitialize: [cloudAccount1, cloudAccount2],
61
+ runnerAppCredentials: {
62
+ id: "test-app-id",
63
+ installationId: "test-installation-id",
64
+ key: "test-private-key",
65
+ },
56
66
  terraformBackend: {
57
67
  cloudAccount: createMockCloudAccount(),
58
68
  },
@@ -60,8 +70,16 @@ describe("initCloudAccounts", () => {
60
70
  });
61
71
  await initCloudAccounts(payload, mockService);
62
72
  expect(initializeMock).toHaveBeenCalledTimes(2);
63
- expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {});
64
- expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {});
73
+ expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
74
+ id: "test-app-id",
75
+ installationId: "test-installation-id",
76
+ key: "test-private-key",
77
+ }, {});
78
+ expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
79
+ id: "test-app-id",
80
+ installationId: "test-installation-id",
81
+ key: "test-private-key",
82
+ }, {});
65
83
  });
66
84
  it("should not call initialize when cloudAccountsToInitialize is empty", async () => {
67
85
  const initializeMock = vi.fn().mockResolvedValue(undefined);
@@ -71,6 +89,11 @@ describe("initCloudAccounts", () => {
71
89
  const payload = createMockPayload({
72
90
  init: {
73
91
  cloudAccountsToInitialize: [],
92
+ runnerAppCredentials: {
93
+ id: "test-app-id",
94
+ installationId: "test-installation-id",
95
+ key: "test-private-key",
96
+ },
74
97
  terraformBackend: {
75
98
  cloudAccount: createMockCloudAccount(),
76
99
  },
@@ -104,12 +127,21 @@ describe("initCloudAccounts", () => {
104
127
  },
105
128
  init: {
106
129
  cloudAccountsToInitialize: [cloudAccount],
130
+ runnerAppCredentials: {
131
+ id: "test-app-id",
132
+ installationId: "test-installation-id",
133
+ key: "test-private-key",
134
+ },
107
135
  terraformBackend: {
108
136
  cloudAccount: createMockCloudAccount(),
109
137
  },
110
138
  },
111
139
  });
112
140
  await initCloudAccounts(payload, mockService);
113
- expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {});
141
+ expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
142
+ id: "test-app-id",
143
+ installationId: "test-installation-id",
144
+ key: "test-private-key",
145
+ }, {});
114
146
  });
115
147
  });
@@ -34,6 +34,11 @@ const createMockPayload = (overrides = {}) => ({
34
34
  },
35
35
  init: {
36
36
  cloudAccountsToInitialize: [],
37
+ runnerAppCredentials: {
38
+ id: "test-app-id",
39
+ installationId: "test-installation-id",
40
+ key: "test-private-key",
41
+ },
37
42
  terraformBackend: {
38
43
  cloudAccount: createMockCloudAccount(),
39
44
  },
@@ -71,6 +76,11 @@ describe("provisionTerraformBackend", () => {
71
76
  },
72
77
  init: {
73
78
  cloudAccountsToInitialize: [],
79
+ runnerAppCredentials: {
80
+ id: "test-app-id",
81
+ installationId: "test-installation-id",
82
+ key: "test-private-key",
83
+ },
74
84
  terraformBackend: {
75
85
  cloudAccount,
76
86
  },
@@ -105,6 +115,11 @@ describe("provisionTerraformBackend", () => {
105
115
  },
106
116
  init: {
107
117
  cloudAccountsToInitialize: [],
118
+ runnerAppCredentials: {
119
+ id: "test-app-id",
120
+ installationId: "test-installation-id",
121
+ key: "test-private-key",
122
+ },
108
123
  terraformBackend: {
109
124
  cloudAccount: backendCloudAccount,
110
125
  },
@@ -1,7 +1,10 @@
1
+ import assert from "node:assert/strict";
1
2
  import { payloadSchema, } from "../generators/environment/prompts.js";
2
3
  export const initCloudAccounts = async (payload, cloudAccountService) => {
3
- if (payload.init) {
4
- await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, payload.tags)));
4
+ if (payload.init && payload.init.cloudAccountsToInitialize.length > 0) {
5
+ const { runnerAppCredentials } = payload.init;
6
+ assert.ok(runnerAppCredentials, "Runner app credentials are required");
7
+ await Promise.all(payload.init.cloudAccountsToInitialize.map((cloudAccount) => cloudAccountService.initialize(cloudAccount, payload.env, runnerAppCredentials, payload.tags)));
5
8
  }
6
9
  };
7
10
  export default function (plop, cloudAccountService) {
@@ -25,6 +25,11 @@ export const getPayload = (includeInit = false) => {
25
25
  if (includeInit) {
26
26
  payload.init = {
27
27
  cloudAccountsToInitialize: [cloudAccount],
28
+ runnerAppCredentials: {
29
+ id: "test-app-id",
30
+ installationId: "test-installation-id",
31
+ key: "test-private-key",
32
+ },
28
33
  terraformBackend: {
29
34
  cloudAccount,
30
35
  },
@@ -1,6 +1,7 @@
1
1
  import inquirer from "inquirer";
2
2
  import { type DynamicPromptsFunction } from "node-plop";
3
3
  import { CloudAccount, CloudAccountRepository, CloudAccountService, CloudRegion } from "../../../../domain/cloud-account.js";
4
+ import { EnvironmentInitStatus } from "../../../../domain/environment.js";
4
5
  type InquirerChoice<T> = inquirer.Separator | {
5
6
  name: string;
6
7
  value: T;
@@ -40,6 +41,11 @@ export declare const payloadSchema: z.ZodObject<{
40
41
  displayName: z.ZodString;
41
42
  id: z.ZodString;
42
43
  }, z.core.$strip>>;
44
+ runnerAppCredentials: z.ZodOptional<z.ZodObject<{
45
+ id: z.ZodString;
46
+ installationId: z.ZodString;
47
+ key: z.ZodString;
48
+ }, z.core.$strip>>;
43
49
  terraformBackend: z.ZodOptional<z.ZodObject<{
44
50
  cloudAccount: z.ZodObject<{
45
51
  csp: z.ZodDefault<z.ZodEnum<{
@@ -65,4 +71,12 @@ export type PromptsDependencies = {
65
71
  declare const prompts: (deps: PromptsDependencies) => DynamicPromptsFunction;
66
72
  export declare const getCloudAccountChoices: (cloudAccounts: CloudAccount[]) => InquirerChoice<CloudAccount>[];
67
73
  export declare const getCloudLocationChoices: (regions: CloudRegion[]) => InquirerChoice<CloudRegion["name"]>[];
74
+ export declare const getCloudAccountToInitialize: (initStatus: EnvironmentInitStatus & {
75
+ initialized: false;
76
+ }) => {
77
+ csp: "azure";
78
+ defaultLocation: string;
79
+ displayName: string;
80
+ id: string;
81
+ }[];
68
82
  export default prompts;
@@ -5,9 +5,11 @@ import * as azure from "../../../azure/locations.js";
5
5
  import { getLogger } from "@logtape/logtape";
6
6
  import { z } from "zod/v4";
7
7
  import { githubRepoSchema, } from "../../../../domain/github-repo.js";
8
+ import { githubAppCredentialsSchema } from "../../../../domain/github.js";
8
9
  import { getGithubRepo } from "../../../github/github-repo.js";
9
10
  const initSchema = z.object({
10
11
  cloudAccountsToInitialize: z.array(cloudAccountSchema),
12
+ runnerAppCredentials: githubAppCredentialsSchema.optional(),
11
13
  terraformBackend: z
12
14
  .object({
13
15
  cloudAccount: cloudAccountSchema,
@@ -25,6 +27,7 @@ export const payloadSchema = z.object({
25
27
  tags: tagsSchema,
26
28
  workspace: workspaceSchema,
27
29
  });
30
+ /* eslint-disable max-lines-per-function */
28
31
  const prompts = (deps) => async (inquirer) => {
29
32
  const logger = getLogger(["gen", "env"]);
30
33
  const github = deps.github ?? (await getGithubRepo());
@@ -93,16 +96,16 @@ const prompts = (deps) => async (inquirer) => {
93
96
  : "Please select a Cost Center.",
94
97
  },
95
98
  {
99
+ filter: (value) => value.trim(),
96
100
  message: "Business unit",
97
101
  name: "tags.BusinessUnit",
98
- transformer: (value) => value.trim(),
99
- validate: (value) => value.trim().length > 0 ? true : "Business Unit cannot be empty.",
102
+ validate: (value) => value.length > 0 ? true : "Business Unit cannot be empty.",
100
103
  },
101
104
  {
105
+ filter: (value) => value.trim(),
102
106
  message: "Management team",
103
107
  name: "tags.ManagementTeam",
104
- transformer: (value) => value.trim(),
105
- validate: (value) => value.trim().length > 0 ? true : "Management Team cannot be empty.",
108
+ validate: (value) => value.length > 0 ? true : "Management Team cannot be empty.",
106
109
  },
107
110
  ]);
108
111
  const payload = payloadSchema.parse({ ...answers, github });
@@ -124,36 +127,66 @@ const prompts = (deps) => async (inquirer) => {
124
127
  if (initStatus.initialized) {
125
128
  return payload;
126
129
  }
130
+ const initConfirm = await inquirer.prompt({
131
+ default: true,
132
+ message: "The environment is not initialized. Do you want to initialize it now?",
133
+ name: "init",
134
+ type: "confirm",
135
+ });
136
+ assert.ok(initConfirm.init, "Can't proceed without initialization");
127
137
  assert.ok(await hasUserPermissionToInitialize(deps.cloudAccountService, payload.env), "You don't have permission to initialize this environment. Ask your Engineering Leader to initialize it for you.");
128
138
  const missingRemoteBackend = initStatus.issues.some((issue) => issue.type === "MISSING_REMOTE_BACKEND");
129
- const initInput = await inquirer.prompt([
130
- {
131
- default: true,
132
- message: "The environment is not initialized. Do you want to initialize it now?",
133
- name: "init",
134
- type: "confirm",
135
- },
136
- {
137
- choices: getCloudAccountChoices(payload.env.cloudAccounts),
138
- message: "Cloud Account to use for the remote Terraform backend",
139
- name: "terraformBackend.cloudAccount",
140
- type: "list",
141
- when: (answers) => answers.init &&
142
- missingRemoteBackend &&
143
- payload.env.cloudAccounts.length > 1,
144
- },
145
- ]);
146
- assert.ok(initInput.init, "Can't proceed without initialization");
139
+ const questions = [];
140
+ let terraformBackend;
141
+ if (missingRemoteBackend) {
142
+ if (payload.env.cloudAccounts.length === 1) {
143
+ terraformBackend = {
144
+ cloudAccount: payload.env.cloudAccounts[0],
145
+ };
146
+ }
147
+ else {
148
+ questions.push({
149
+ choices: getCloudAccountChoices(payload.env.cloudAccounts),
150
+ message: "Cloud Account to use for the remote Terraform backend",
151
+ name: "terraformBackend.cloudAccount",
152
+ type: "list",
153
+ });
154
+ }
155
+ }
156
+ const cloudAccountsNotInitialized = initStatus.issues.some((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED");
157
+ let runnerAppCredentials;
158
+ if (cloudAccountsNotInitialized) {
159
+ questions.push({
160
+ filter: (value) => value.trim(),
161
+ message: "GitHub Runner App ID",
162
+ name: "runnerAppCredentials.id",
163
+ type: "input",
164
+ validate: (value) => value.length > 0,
165
+ }, {
166
+ filter: (value) => value.trim(),
167
+ message: "GitHub Runner App Installation ID",
168
+ name: "runnerAppCredentials.installationId",
169
+ type: "input",
170
+ validate: (value) => value.length > 0,
171
+ }, {
172
+ filter: (value) => value.trim(),
173
+ message: "GitHub Runner App Private Key",
174
+ name: "runnerAppCredentials.key",
175
+ type: "editor",
176
+ validate: (value) => value.length > 0,
177
+ });
178
+ }
179
+ const initInput = await inquirer.prompt(questions);
180
+ if (initInput.runnerAppCredentials) {
181
+ runnerAppCredentials = initInput.runnerAppCredentials;
182
+ }
183
+ if (initInput.terraformBackend) {
184
+ terraformBackend = initInput.terraformBackend;
185
+ }
147
186
  payload.init = payloadSchema.shape.init.parse({
148
- cloudAccountsToInitialize: initStatus.issues
149
- .filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
150
- .map((issue) => issue.cloudAccount),
151
- terraformBackend: missingRemoteBackend
152
- ? {
153
- cloudAccount: initInput.terraformBackend?.cloudAccount ||
154
- payload.env.cloudAccounts[0],
155
- }
156
- : undefined,
187
+ cloudAccountsToInitialize: getCloudAccountToInitialize(initStatus),
188
+ runnerAppCredentials,
189
+ terraformBackend,
157
190
  });
158
191
  return payload;
159
192
  };
@@ -163,4 +196,7 @@ export const getCloudAccountChoices = (cloudAccounts) => cloudAccounts.map((acco
163
196
  value: account,
164
197
  }));
165
198
  export const getCloudLocationChoices = (regions) => regions.map((r) => ({ name: r.displayName, value: r.name }));
199
+ export const getCloudAccountToInitialize = (initStatus) => initStatus.issues
200
+ .filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
201
+ .map((issue) => issue.cloudAccount);
166
202
  export default prompts;
@@ -5,5 +5,21 @@ import { Payload as MonorepoPayload } from "../plop/generators/monorepo/index.js
5
5
  export declare const setMonorepoGenerator: (plop: NodePlopAPI) => void;
6
6
  export declare const getPlopInstance: () => Promise<NodePlopAPI>;
7
7
  export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: GitHubService) => Promise<MonorepoPayload>;
8
- export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github: GitHubRepo) => Promise<void>;
9
- export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github: GitHubRepo) => void;
8
+ /**
9
+ * Run the deployment environment generator
10
+ *
11
+ * @param plop - The plop instance
12
+ * @param github - Optional GitHub repository info. When provided (by init command),
13
+ * uses the explicitly passed repository. When omitted (by add command),
14
+ * the generator infers it from the local git context.
15
+ */
16
+ export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<void>;
17
+ /**
18
+ * Configure the deployment environment generator
19
+ *
20
+ * @param plop - The plop instance
21
+ * @param github - Optional GitHub repository info. When provided (by init command),
22
+ * uses the explicitly passed repository. When omitted (by add command),
23
+ * the generator infers it from the local git context.
24
+ */
25
+ export declare const setDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => void;
@@ -54,6 +54,14 @@ export const runMonorepoGenerator = async (plop, githubService) => {
54
54
  });
55
55
  return payload;
56
56
  };
57
+ /**
58
+ * Run the deployment environment generator
59
+ *
60
+ * @param plop - The plop instance
61
+ * @param github - Optional GitHub repository info. When provided (by init command),
62
+ * uses the explicitly passed repository. When omitted (by add command),
63
+ * the generator infers it from the local git context.
64
+ */
57
65
  export const runDeploymentEnvironmentGenerator = async (plop, github) => {
58
66
  setDeploymentEnvironmentGenerator(plop, github);
59
67
  const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
@@ -64,6 +72,14 @@ export const runDeploymentEnvironmentGenerator = async (plop, github) => {
64
72
  text: "Creating environment...",
65
73
  });
66
74
  };
75
+ /**
76
+ * Configure the deployment environment generator
77
+ *
78
+ * @param plop - The plop instance
79
+ * @param github - Optional GitHub repository info. When provided (by init command),
80
+ * uses the explicitly passed repository. When omitted (by add command),
81
+ * the generator infers it from the local git context.
82
+ */
67
83
  export const setDeploymentEnvironmentGenerator = (plop, github) => {
68
84
  const credential = new AzureCliCredential();
69
85
  const cloudAccountRepository = new AzureSubscriptionRepository(credential);