@pagopa/dx-cli 0.21.1 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +9 -1
  2. package/dist/adapters/azure/cloud-account-service.js +7 -0
  3. package/dist/adapters/commander/commands/__tests__/add.test.js +12 -4
  4. package/dist/adapters/commander/commands/__tests__/init.test.d.ts +4 -0
  5. package/dist/adapters/commander/commands/__tests__/init.test.js +48 -0
  6. package/dist/adapters/commander/commands/add.js +5 -2
  7. package/dist/adapters/commander/commands/init.d.ts +2 -0
  8. package/dist/adapters/commander/commands/init.js +30 -4
  9. package/dist/adapters/pagopa-technology/__tests__/authorization.test.js +349 -31
  10. package/dist/adapters/pagopa-technology/azure-authorization-config.d.ts +13 -0
  11. package/dist/adapters/pagopa-technology/azure-authorization-config.js +43 -0
  12. package/dist/adapters/pagopa-technology/{authorization.d.ts → azure-authorization.d.ts} +2 -2
  13. package/dist/adapters/pagopa-technology/azure-authorization.js +239 -0
  14. package/dist/adapters/plop/actions/__tests__/init-cloud-accounts.test.js +7 -0
  15. package/dist/adapters/plop/actions/__tests__/provision-terraform-backend.test.js +3 -0
  16. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +1 -0
  17. package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +156 -2
  18. package/dist/adapters/plop/generators/environment/prompts.d.ts +10 -0
  19. package/dist/adapters/plop/generators/environment/prompts.js +45 -1
  20. package/dist/domain/authorization.d.ts +6 -9
  21. package/dist/domain/authorization.js +27 -10
  22. package/dist/domain/github.d.ts +1 -0
  23. package/dist/domain/github.js +1 -0
  24. package/dist/index.js +2 -2
  25. package/dist/use-cases/__tests__/request-authorization.test.js +5 -3
  26. package/dist/use-cases/request-authorization.d.ts +2 -2
  27. package/dist/use-cases/request-authorization.js +2 -2
  28. package/package.json +1 -1
  29. package/dist/adapters/pagopa-technology/authorization.js +0 -124
@@ -0,0 +1,239 @@
1
+ /**
2
+ * PagoPA Technology Azure 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, JSON 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 { z } from "zod";
12
+ import { AuthorizationError, AuthorizationResult, InvalidAuthorizationFileFormatError, } from "../../domain/authorization.js";
13
+ import { DEFAULT_GROUP_SPECS, makeAzureAdGroupName, } from "./azure-authorization-config.js";
14
+ const azureAuthorizationFileSchema = z
15
+ .object({
16
+ directory_readers: z
17
+ .object({
18
+ service_principals_name: z.array(z.string()),
19
+ })
20
+ .loose(),
21
+ groups: z
22
+ .array(z
23
+ .object({
24
+ members: z.array(z.string()),
25
+ name: z.string(),
26
+ roles: z.array(z.string()),
27
+ })
28
+ .loose())
29
+ .optional(),
30
+ })
31
+ .loose();
32
+ /**
33
+ * Checks if two role arrays are equivalent (same roles, order-independent).
34
+ */
35
+ const rolesAreEqual = (roles1, roles2) => {
36
+ if (roles1.length !== roles2.length) {
37
+ return false;
38
+ }
39
+ const sorted1 = [...roles1].sort();
40
+ const sorted2 = [...roles2].sort();
41
+ return sorted1.every((role, idx) => role === sorted2[idx]);
42
+ };
43
+ /**
44
+ * Adds or updates the AD groups array in the parsed authorization JSON.
45
+ * - Missing default groups are added with empty members.
46
+ * - Existing groups with wrong roles have their roles updated; members are preserved.
47
+ * - Custom (non-default) groups are preserved unchanged.
48
+ * Returns the updated JSON along with a flag indicating whether anything changed.
49
+ */
50
+ const upsertGroups = (jsonContent, prefix, envShort) => {
51
+ const expectedByName = new Map(DEFAULT_GROUP_SPECS.map((spec) => [
52
+ makeAzureAdGroupName(prefix, envShort, spec.groupName),
53
+ spec,
54
+ ]));
55
+ const existingGroups = jsonContent.groups ?? [];
56
+ const seenDefaults = new Set();
57
+ // Walk existing groups in their original order, updating roles where needed
58
+ const finalGroups = [];
59
+ let groupsChanged = false;
60
+ for (const existing of existingGroups) {
61
+ const spec = expectedByName.get(existing.name);
62
+ if (!spec) {
63
+ // Custom group — preserve as-is
64
+ finalGroups.push(existing);
65
+ }
66
+ else {
67
+ seenDefaults.add(existing.name);
68
+ if (!rolesAreEqual(existing.roles, spec.roles)) {
69
+ // Roles differ — update roles, preserve members and any extra fields
70
+ finalGroups.push({ ...existing, roles: [...spec.roles] });
71
+ groupsChanged = true;
72
+ }
73
+ else {
74
+ finalGroups.push(existing);
75
+ }
76
+ }
77
+ }
78
+ // Append missing default groups at the end
79
+ for (const spec of DEFAULT_GROUP_SPECS) {
80
+ const name = makeAzureAdGroupName(prefix, envShort, spec.groupName);
81
+ if (!seenDefaults.has(name)) {
82
+ finalGroups.push({ members: [], name, roles: [...spec.roles] });
83
+ groupsChanged = true;
84
+ }
85
+ }
86
+ return { groupsChanged, json: { ...jsonContent, groups: finalGroups } };
87
+ };
88
+ /**
89
+ * Parses and validates the authorization file content (JSON parsing + schema validation).
90
+ */
91
+ const parseAuthorizationFile = (content) => {
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(content);
95
+ }
96
+ catch {
97
+ return err(new InvalidAuthorizationFileFormatError("File content is not valid JSON"));
98
+ }
99
+ const result = azureAuthorizationFileSchema.safeParse(parsed);
100
+ if (!result.success) {
101
+ return err(new InvalidAuthorizationFileFormatError("Could not find directory_readers.service_principals_name list"));
102
+ }
103
+ return ok(result.data);
104
+ };
105
+ /**
106
+ * Produces commit message, PR title, and PR body tailored to what actually changed.
107
+ */
108
+ const makeChangeDescription = (subscriptionName, bootstrapIdentityId, identityAdded, groupsChanged) => {
109
+ if (identityAdded && groupsChanged) {
110
+ const title = `Add bootstrap identity and AD groups for ${subscriptionName}`;
111
+ return {
112
+ body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers and configures AD groups for subscription \`${subscriptionName}\`.`,
113
+ message: title,
114
+ title,
115
+ };
116
+ }
117
+ if (identityAdded) {
118
+ const title = `Add bootstrap identity for ${subscriptionName}`;
119
+ return {
120
+ body: `This PR adds the bootstrap identity \`${bootstrapIdentityId}\` to the directory readers for subscription \`${subscriptionName}\`.`,
121
+ message: title,
122
+ title,
123
+ };
124
+ }
125
+ const title = `Configure AD groups for ${subscriptionName}`;
126
+ return {
127
+ body: `This PR configures AD groups for subscription \`${subscriptionName}\`.`,
128
+ message: title,
129
+ title,
130
+ };
131
+ };
132
+ /**
133
+ * Ensures the given identity is present in the service_principals_name list.
134
+ * Returns the (possibly updated) JSON and whether the identity was newly added.
135
+ * Never fails: if the identity already exists it is a no-op with identityAdded = false.
136
+ */
137
+ const ensureIdentity = (jsonContent, identityId) => {
138
+ if (jsonContent.directory_readers.service_principals_name.includes(identityId)) {
139
+ return { identityAdded: false, json: jsonContent };
140
+ }
141
+ return {
142
+ identityAdded: true,
143
+ json: {
144
+ ...jsonContent,
145
+ directory_readers: {
146
+ ...jsonContent.directory_readers,
147
+ service_principals_name: [
148
+ ...jsonContent.directory_readers.service_principals_name,
149
+ identityId,
150
+ ],
151
+ },
152
+ },
153
+ };
154
+ };
155
+ const REPO_OWNER = "pagopa";
156
+ const REPO_NAME = "eng-azure-authorization";
157
+ const BASE_BRANCH = "main";
158
+ export const makeAzureAuthorizationService = (gitHubService) => ({
159
+ requestAuthorization(input) {
160
+ const logger = getLogger(["dx-cli", "pagopa-azure-authorization"]);
161
+ const { bootstrapIdentityId, envShort, prefix, repoName, subscriptionName, } = input;
162
+ const filePath = `src/azure-subscriptions/subscriptions/${subscriptionName}/terraform.tfvars.json`;
163
+ const branchName = `feats/add-${repoName}-${subscriptionName}-bootstrap-identity`;
164
+ return (
165
+ // Step 1: Read file from main to determine if changes are needed
166
+ ResultAsync.fromPromise(gitHubService.getFileContent({
167
+ owner: REPO_OWNER,
168
+ path: filePath,
169
+ ref: BASE_BRANCH,
170
+ repo: REPO_NAME,
171
+ }), () => new AuthorizationError(`Unable to get ${filePath} in ${REPO_OWNER}/${REPO_NAME}`))
172
+ .orTee((error) => {
173
+ logger.error(error.message);
174
+ })
175
+ // Step 2: Parse file, ensure identity, upsert groups, detect no-op
176
+ .andThen(({ content, sha }) => {
177
+ const parseResult = parseAuthorizationFile(content);
178
+ if (parseResult.isErr()) {
179
+ logger.error("Failed to modify tfvars", {
180
+ error: parseResult.error.message,
181
+ });
182
+ return errAsync(parseResult.error);
183
+ }
184
+ const parsed = parseResult.value;
185
+ const { identityAdded, json: withIdentity } = ensureIdentity(parsed, bootstrapIdentityId);
186
+ if (!identityAdded) {
187
+ logger.warn("Identity already exists, checking groups", {
188
+ identityId: bootstrapIdentityId,
189
+ subscription: subscriptionName,
190
+ });
191
+ }
192
+ const { groupsChanged, json: updatedJson } = upsertGroups(withIdentity, prefix, envShort);
193
+ if (!identityAdded && !groupsChanged) {
194
+ // Nothing to do — no branch created, no PR needed.
195
+ logger.info("No changes needed, skipping PR", {
196
+ subscription: subscriptionName,
197
+ });
198
+ return okAsync(new AuthorizationResult());
199
+ }
200
+ const { body, message, title } = makeChangeDescription(subscriptionName, bootstrapIdentityId, identityAdded, groupsChanged);
201
+ // Step 3: Create branch only when changes are needed
202
+ return (ResultAsync.fromPromise(gitHubService.createBranch({
203
+ branchName,
204
+ fromRef: BASE_BRANCH,
205
+ owner: REPO_OWNER,
206
+ repo: REPO_NAME,
207
+ }), () => new AuthorizationError(`Unable to create branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`))
208
+ .orTee((error) => {
209
+ logger.error(error.message);
210
+ })
211
+ // Step 4: Update file on the branch using the SHA read from main
212
+ .andThen(() => ResultAsync.fromPromise(gitHubService.updateFile({
213
+ branch: branchName,
214
+ content: JSON.stringify(updatedJson, null, 2),
215
+ message,
216
+ owner: REPO_OWNER,
217
+ path: filePath,
218
+ repo: REPO_NAME,
219
+ sha,
220
+ }), () => new AuthorizationError(`Unable to update ${filePath} on branch ${branchName} in ${REPO_OWNER}/${REPO_NAME}`)))
221
+ .orTee((error) => {
222
+ logger.error(error.message);
223
+ })
224
+ // Step 5: Create PR
225
+ .andThen(() => ResultAsync.fromPromise(gitHubService.createPullRequest({
226
+ base: BASE_BRANCH,
227
+ body,
228
+ head: branchName,
229
+ owner: REPO_OWNER,
230
+ repo: REPO_NAME,
231
+ title,
232
+ }), () => new AuthorizationError(`Unable to create pull request from ${branchName} to ${BASE_BRANCH} in ${REPO_OWNER}/${REPO_NAME}`)))
233
+ .orTee((error) => {
234
+ logger.error(error.message);
235
+ })
236
+ .map((pr) => new AuthorizationResult(pr.url)));
237
+ }));
238
+ },
239
+ });
@@ -36,6 +36,7 @@ const createMockPayload = (overrides = {}) => ({
36
36
  init: {
37
37
  cloudAccountsToInitialize: [],
38
38
  runnerAppCredentials: {
39
+ clientId: "test-app-client-id",
39
40
  id: "test-app-id",
40
41
  installationId: "test-installation-id",
41
42
  key: "test-private-key",
@@ -67,6 +68,7 @@ describe("initCloudAccounts", () => {
67
68
  init: {
68
69
  cloudAccountsToInitialize: [cloudAccount1, cloudAccount2],
69
70
  runnerAppCredentials: {
71
+ clientId: "test-app-client-id",
70
72
  id: "test-app-id",
71
73
  installationId: "test-installation-id",
72
74
  key: "test-private-key",
@@ -79,6 +81,7 @@ describe("initCloudAccounts", () => {
79
81
  await initCloudAccounts(payload, mockService, createMockGitHubService());
80
82
  expect(initializeMock).toHaveBeenCalledTimes(2);
81
83
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount1, expect.objectContaining({ name: "prod", prefix: "io" }), {
84
+ clientId: "test-app-client-id",
82
85
  id: "test-app-id",
83
86
  installationId: "test-installation-id",
84
87
  key: "test-private-key",
@@ -87,6 +90,7 @@ describe("initCloudAccounts", () => {
87
90
  repo: "dx",
88
91
  }, expect.any(Object), {});
89
92
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount2, expect.objectContaining({ name: "prod", prefix: "io" }), {
93
+ clientId: "test-app-client-id",
90
94
  id: "test-app-id",
91
95
  installationId: "test-installation-id",
92
96
  key: "test-private-key",
@@ -104,6 +108,7 @@ describe("initCloudAccounts", () => {
104
108
  init: {
105
109
  cloudAccountsToInitialize: [],
106
110
  runnerAppCredentials: {
111
+ clientId: "test-app-client-id",
107
112
  id: "test-app-id",
108
113
  installationId: "test-installation-id",
109
114
  key: "test-private-key",
@@ -142,6 +147,7 @@ describe("initCloudAccounts", () => {
142
147
  init: {
143
148
  cloudAccountsToInitialize: [cloudAccount],
144
149
  runnerAppCredentials: {
150
+ clientId: "test-app-client-id",
145
151
  id: "test-app-id",
146
152
  installationId: "test-installation-id",
147
153
  key: "test-private-key",
@@ -153,6 +159,7 @@ describe("initCloudAccounts", () => {
153
159
  });
154
160
  await initCloudAccounts(payload, mockService, createMockGitHubService());
155
161
  expect(initializeMock).toHaveBeenCalledWith(cloudAccount, expect.objectContaining({ name: "uat", prefix: "pagopa" }), {
162
+ clientId: "test-app-client-id",
156
163
  id: "test-app-id",
157
164
  installationId: "test-installation-id",
158
165
  key: "test-private-key",
@@ -35,6 +35,7 @@ const createMockPayload = (overrides = {}) => ({
35
35
  init: {
36
36
  cloudAccountsToInitialize: [],
37
37
  runnerAppCredentials: {
38
+ clientId: "test-app-client-id",
38
39
  id: "test-app-id",
39
40
  installationId: "test-installation-id",
40
41
  key: "test-private-key",
@@ -77,6 +78,7 @@ describe("provisionTerraformBackend", () => {
77
78
  init: {
78
79
  cloudAccountsToInitialize: [],
79
80
  runnerAppCredentials: {
81
+ clientId: "test-app-client-id",
80
82
  id: "test-app-id",
81
83
  installationId: "test-installation-id",
82
84
  key: "test-private-key",
@@ -116,6 +118,7 @@ describe("provisionTerraformBackend", () => {
116
118
  init: {
117
119
  cloudAccountsToInitialize: [],
118
120
  runnerAppCredentials: {
121
+ clientId: "test-app-client-id",
119
122
  id: "test-app-id",
120
123
  installationId: "test-installation-id",
121
124
  key: "test-private-key",
@@ -26,6 +26,7 @@ export const getPayload = (includeInit = false) => {
26
26
  payload.init = {
27
27
  cloudAccountsToInitialize: [cloudAccount],
28
28
  runnerAppCredentials: {
29
+ clientId: "test-app-client-id",
29
30
  id: "test-app-id",
30
31
  installationId: "test-installation-id",
31
32
  key: "test-private-key",
@@ -1,6 +1,7 @@
1
1
  // Tests for workspaceSchema transforms (lowercase and trim on domain).
2
- import { describe, expect, it } from "vitest";
3
- import { workspaceSchema } from "../prompts.js";
2
+ import inquirer from "inquirer";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import prompts, { formatInitializationDetails, workspaceSchema, } from "../prompts.js";
4
5
  describe("workspaceSchema — domain transforms", () => {
5
6
  it("lowercases an uppercase domain", () => {
6
7
  const result = workspaceSchema.safeParse({ domain: "API" });
@@ -23,3 +24,156 @@ describe("workspaceSchema — domain transforms", () => {
23
24
  expect(result.success && result.data.domain).toBe("");
24
25
  });
25
26
  });
27
+ describe("formatInitializationDetails", () => {
28
+ const account = (overrides = {}) => ({
29
+ csp: "azure",
30
+ defaultLocation: "italynorth",
31
+ displayName: "DEV-FooBar",
32
+ id: "sub-123",
33
+ ...overrides,
34
+ });
35
+ const notInitializedStatus = (issues) => ({
36
+ initialized: false,
37
+ issues,
38
+ });
39
+ it("lists each uninitialized cloud account by displayName", () => {
40
+ const status = notInitializedStatus([
41
+ {
42
+ cloudAccount: account({ displayName: "DEV-A" }),
43
+ type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
44
+ },
45
+ {
46
+ cloudAccount: account({ displayName: "DEV-B" }),
47
+ type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
48
+ },
49
+ ]);
50
+ const output = formatInitializationDetails(status);
51
+ expect(output).toContain('Azure subscription "DEV-A"');
52
+ expect(output).toContain('Azure subscription "DEV-B"');
53
+ expect(output).toContain("managed identity");
54
+ expect(output).toContain("OIDC");
55
+ expect(output).toContain("ARM_CLIENT_ID");
56
+ expect(output).toContain("ARM_SUBSCRIPTION_ID");
57
+ expect(output).not.toContain("ARM_TENANT_ID");
58
+ expect(output).toContain("Key Vault");
59
+ });
60
+ it("includes the Terraform backend section when MISSING_REMOTE_BACKEND issue is present", () => {
61
+ const status = notInitializedStatus([
62
+ { cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
63
+ { type: "MISSING_REMOTE_BACKEND" },
64
+ ]);
65
+ const output = formatInitializationDetails(status);
66
+ expect(output).toContain("Terraform remote backend");
67
+ expect(output).toContain("Storage Account");
68
+ });
69
+ it("omits the Terraform backend section when no MISSING_REMOTE_BACKEND issue is present", () => {
70
+ const status = notInitializedStatus([
71
+ { cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
72
+ ]);
73
+ const output = formatInitializationDetails(status);
74
+ expect(output).not.toContain("Terraform remote backend");
75
+ });
76
+ it("omits the cloud account section when no CLOUD_ACCOUNT_NOT_INITIALIZED issues are present", () => {
77
+ const status = notInitializedStatus([{ type: "MISSING_REMOTE_BACKEND" }]);
78
+ const output = formatInitializationDetails(status);
79
+ expect(output).not.toContain("Azure subscription");
80
+ expect(output).toContain("Terraform remote backend");
81
+ });
82
+ it("returns an empty string when there are no relevant issues", () => {
83
+ const status = notInitializedStatus([]);
84
+ expect(formatInitializationDetails(status)).toBe("");
85
+ });
86
+ });
87
+ describe("prompts", () => {
88
+ it("prompts for the GitHub runner app client ID when initialization is required", async () => {
89
+ const cloudAccount = {
90
+ csp: "azure",
91
+ defaultLocation: "italynorth",
92
+ displayName: "DEV-FooBar",
93
+ id: "sub-123",
94
+ };
95
+ const cloudAccountRepository = {
96
+ list: vi.fn().mockResolvedValue([cloudAccount]),
97
+ };
98
+ const cloudAccountService = {
99
+ getTerraformBackend: vi.fn().mockResolvedValue(undefined),
100
+ hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true),
101
+ initialize: vi.fn().mockResolvedValue(undefined),
102
+ isInitialized: vi.fn().mockResolvedValue(false),
103
+ provisionTerraformBackend: vi.fn().mockResolvedValue(undefined),
104
+ };
105
+ const promptSpy = vi.spyOn(inquirer, "prompt");
106
+ const consoleLogSpy = vi
107
+ .spyOn(console, "log")
108
+ .mockImplementation(() => undefined);
109
+ promptSpy
110
+ .mockResolvedValueOnce({
111
+ env: {
112
+ cloudAccounts: [cloudAccount],
113
+ name: "dev",
114
+ prefix: "dx",
115
+ },
116
+ tags: {
117
+ BusinessUnit: "Platform",
118
+ CostCenter: "TS000",
119
+ ManagementTeam: "Engineering",
120
+ },
121
+ workspace: {
122
+ domain: "payments",
123
+ },
124
+ })
125
+ .mockResolvedValueOnce({
126
+ [cloudAccount.id]: "italynorth",
127
+ })
128
+ .mockResolvedValueOnce({
129
+ init: true,
130
+ })
131
+ .mockResolvedValueOnce({
132
+ runnerAppCredentials: {
133
+ clientId: "app-client-id",
134
+ id: "app-id",
135
+ installationId: "installation-id",
136
+ key: "private-key",
137
+ },
138
+ });
139
+ try {
140
+ const result = await prompts({
141
+ cloudAccountRepository,
142
+ cloudAccountService,
143
+ github: {
144
+ owner: "pagopa",
145
+ repo: "dx",
146
+ },
147
+ })(inquirer);
148
+ const runnerCredentialQuestions = promptSpy.mock.calls[3]?.[0];
149
+ expect(runnerCredentialQuestions).toEqual(expect.arrayContaining([
150
+ expect.objectContaining({
151
+ message: "GitHub Runner App ID",
152
+ name: "runnerAppCredentials.id",
153
+ }),
154
+ expect.objectContaining({
155
+ message: "GitHub Runner App Client ID",
156
+ name: "runnerAppCredentials.clientId",
157
+ }),
158
+ expect.objectContaining({
159
+ message: "GitHub Runner App Installation ID",
160
+ name: "runnerAppCredentials.installationId",
161
+ }),
162
+ expect.objectContaining({
163
+ message: "GitHub Runner App Private Key",
164
+ name: "runnerAppCredentials.key",
165
+ }),
166
+ ]));
167
+ expect(result.init?.runnerAppCredentials).toEqual({
168
+ clientId: "app-client-id",
169
+ id: "app-id",
170
+ installationId: "installation-id",
171
+ key: "private-key",
172
+ });
173
+ }
174
+ finally {
175
+ promptSpy.mockRestore();
176
+ consoleLogSpy.mockRestore();
177
+ }
178
+ });
179
+ });
@@ -42,6 +42,7 @@ export declare const payloadSchema: z.ZodObject<{
42
42
  id: z.ZodString;
43
43
  }, z.core.$strip>>;
44
44
  runnerAppCredentials: z.ZodOptional<z.ZodObject<{
45
+ clientId: z.ZodString;
45
46
  id: z.ZodString;
46
47
  installationId: z.ZodString;
47
48
  key: z.ZodString;
@@ -79,4 +80,13 @@ export declare const getCloudAccountToInitialize: (initStatus: EnvironmentInitSt
79
80
  displayName: string;
80
81
  id: string;
81
82
  }[];
83
+ /**
84
+ * Build a human-readable description of the resources that will be created
85
+ * when initializing an environment, so users see the side effects before
86
+ * confirming. The exact resource names are intentionally omitted to avoid
87
+ * coupling the prompt copy to internal naming conventions.
88
+ */
89
+ export declare const formatInitializationDetails: (initStatus: EnvironmentInitStatus & {
90
+ initialized: false;
91
+ }) => string;
82
92
  export default prompts;
@@ -1,3 +1,4 @@
1
+ import chalk from "chalk";
1
2
  import * as assert from "node:assert/strict";
2
3
  import { cloudAccountSchema, } from "../../../../domain/cloud-account.js";
3
4
  import { environmentSchema, getInitializationStatus, hasUserPermissionToInitialize, } from "../../../../domain/environment.js";
@@ -126,9 +127,10 @@ const prompts = (deps) => async (inquirer) => {
126
127
  if (initStatus.initialized) {
127
128
  return payload;
128
129
  }
130
+ console.log(formatInitializationDetails(initStatus));
129
131
  const initConfirm = await inquirer.prompt({
130
132
  default: true,
131
- message: "The environment is not initialized. Do you want to initialize it now?",
133
+ message: `The environment "${payload.env.name}" is not initialized. Proceed with the setup above?`,
132
134
  name: "init",
133
135
  type: "confirm",
134
136
  });
@@ -161,6 +163,12 @@ const prompts = (deps) => async (inquirer) => {
161
163
  name: "runnerAppCredentials.id",
162
164
  type: "input",
163
165
  validate: (value) => value.length > 0,
166
+ }, {
167
+ filter: (value) => value.trim(),
168
+ message: "GitHub Runner App Client ID",
169
+ name: "runnerAppCredentials.clientId",
170
+ type: "input",
171
+ validate: (value) => value.length > 0,
164
172
  }, {
165
173
  filter: (value) => value.trim(),
166
174
  message: "GitHub Runner App Installation ID",
@@ -198,4 +206,40 @@ export const getCloudLocationChoices = (regions) => regions.map((r) => ({ name:
198
206
  export const getCloudAccountToInitialize = (initStatus) => initStatus.issues
199
207
  .filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
200
208
  .map((issue) => issue.cloudAccount);
209
+ /**
210
+ * Build a human-readable description of the resources that will be created
211
+ * when initializing an environment, so users see the side effects before
212
+ * confirming. The exact resource names are intentionally omitted to avoid
213
+ * coupling the prompt copy to internal naming conventions.
214
+ */
215
+ export const formatInitializationDetails = (initStatus) => {
216
+ const accountsToInit = getCloudAccountToInitialize(initStatus);
217
+ const missingBackend = initStatus.issues.some((issue) => issue.type === "MISSING_REMOTE_BACKEND");
218
+ const sections = [];
219
+ for (const account of accountsToInit) {
220
+ sections.push([
221
+ chalk.bold.cyan(` Azure subscription "${account.displayName}":`),
222
+ ` • Bootstrap resource group and managed identity with subscription-scoped roles`,
223
+ ` • GitHub OIDC federated identity credential`,
224
+ ` • GitHub environment secrets (ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID)`,
225
+ ` • Common Key Vault storing the GitHub runner app credentials`,
226
+ ].join("\n"));
227
+ }
228
+ if (missingBackend) {
229
+ sections.push([
230
+ chalk.bold.cyan(` Terraform remote backend:`),
231
+ ` • Azure resource group and Storage Account for the Terraform state`,
232
+ ].join("\n"));
233
+ }
234
+ if (sections.length === 0) {
235
+ return "";
236
+ }
237
+ return [
238
+ "",
239
+ chalk.bold("The following resources will be created:"),
240
+ "",
241
+ ...sections,
242
+ "",
243
+ ].join("\n");
244
+ };
201
245
  export default prompts;
@@ -8,10 +8,12 @@
8
8
  import { ResultAsync } from "neverthrow";
9
9
  import { z } from "zod/v4";
10
10
  /**
11
- * Input validation schema for the request authorization use case.
11
+ * Input validation schema for the authorization workflow.
12
12
  */
13
13
  export declare const requestAuthorizationInputSchema: z.ZodObject<{
14
14
  bootstrapIdentityId: z.core.$ZodBranded<z.ZodString, "BootstrapIdentityId", "out">;
15
+ envShort: z.core.$ZodBranded<z.ZodString, "EnvShort", "out">;
16
+ prefix: z.core.$ZodBranded<z.ZodString, "ResourcePrefix", "out">;
15
17
  repoName: z.ZodString;
16
18
  subscriptionName: z.core.$ZodBranded<z.ZodString, "SubscriptionName", "out">;
17
19
  }, z.core.$strip>;
@@ -31,16 +33,11 @@ export declare class AuthorizationError extends Error {
31
33
  }
32
34
  /**
33
35
  * Result returned by a successful authorization request.
36
+ * When url is undefined, the workflow was a no-op (nothing changed, no PR created).
34
37
  */
35
38
  export declare class AuthorizationResult {
36
- readonly url: string;
37
- constructor(url: string);
38
- }
39
- /**
40
- * Error thrown when attempting to add an identity that already exists.
41
- */
42
- export declare class IdentityAlreadyExistsError extends AuthorizationError {
43
- constructor(identityId: string);
39
+ readonly url?: string | undefined;
40
+ constructor(url?: string | undefined);
44
41
  }
45
42
  /**
46
43
  * Error thrown when the authorization configuration format is invalid or cannot be parsed.
@@ -29,10 +29,35 @@ const BootstrapIdentityId = z
29
29
  })
30
30
  .brand();
31
31
  /**
32
- * Input validation schema for the request authorization use case.
32
+ * Branded type for resource prefix (e.g., "dx", "io").
33
+ * Validates that the prefix contains only lowercase letters to prevent injection.
34
+ */
35
+ const ResourcePrefix = z
36
+ .string()
37
+ .min(1)
38
+ .regex(/^[a-z]+$/, {
39
+ message: "Resource prefix may contain only lowercase letters",
40
+ })
41
+ .brand();
42
+ /**
43
+ * Branded type for environment short name (e.g., "d", "u", "p").
44
+ * Validates single lowercase letter for environment.
45
+ */
46
+ const EnvShort = z
47
+ .string()
48
+ .min(1)
49
+ .max(1)
50
+ .regex(/^[a-z]$/, {
51
+ message: "Environment short name must be a single lowercase letter",
52
+ })
53
+ .brand();
54
+ /**
55
+ * Input validation schema for the authorization workflow.
33
56
  */
34
57
  export const requestAuthorizationInputSchema = z.object({
35
58
  bootstrapIdentityId: BootstrapIdentityId,
59
+ envShort: EnvShort,
60
+ prefix: ResourcePrefix,
36
61
  repoName: z.string().min(1),
37
62
  subscriptionName: SubscriptionName,
38
63
  });
@@ -47,6 +72,7 @@ export class AuthorizationError extends Error {
47
72
  }
48
73
  /**
49
74
  * Result returned by a successful authorization request.
75
+ * When url is undefined, the workflow was a no-op (nothing changed, no PR created).
50
76
  */
51
77
  export class AuthorizationResult {
52
78
  url;
@@ -54,15 +80,6 @@ export class AuthorizationResult {
54
80
  this.url = url;
55
81
  }
56
82
  }
57
- /**
58
- * Error thrown when attempting to add an identity that already exists.
59
- */
60
- export class IdentityAlreadyExistsError extends AuthorizationError {
61
- constructor(identityId) {
62
- super(`Identity "${identityId}" already exists`);
63
- this.name = "IdentityAlreadyExistsError";
64
- }
65
- }
66
83
  /**
67
84
  * Error thrown when the authorization configuration format is invalid or cannot be parsed.
68
85
  */