@pagopa/dx-cli 0.18.8 → 0.18.10

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.
@@ -19,14 +19,14 @@ describe("LocalCodemodRegistry", () => {
19
19
  registry.add(a);
20
20
  registry.add(b);
21
21
  const result = await registry.getAll();
22
+ expect.assertions(3);
22
23
  expect(result.isOk()).toBe(true);
23
- if (result.isOk()) {
24
- expect(result.value).toHaveLength(2);
25
- expect(result.value).toEqual(expect.arrayContaining([
26
- expect.objectContaining({ id: "a" }),
27
- expect.objectContaining({ id: "b" }),
28
- ]));
29
- }
24
+ const expected = result.unwrapOr([]);
25
+ expect(expected).toHaveLength(2);
26
+ expect(expected).toEqual(expect.arrayContaining([
27
+ expect.objectContaining({ id: "a" }),
28
+ expect.objectContaining({ id: "b" }),
29
+ ]));
30
30
  });
31
31
  it("retrieves a codemod by id and returns undefined when missing", async () => {
32
32
  const registry = new LocalCodemodRegistry();
@@ -34,14 +34,12 @@ describe("LocalCodemodRegistry", () => {
34
34
  registry.add(a);
35
35
  const found = await registry.getById("a");
36
36
  expect(found.isOk()).toBe(true);
37
- if (found.isOk()) {
38
- expect(found.value).toBe(a);
39
- }
37
+ const expected = found.unwrapOr(undefined);
38
+ expect(expected).toBe(a);
40
39
  const missing = await registry.getById("nope");
41
40
  expect(missing.isOk()).toBe(true);
42
- if (missing.isOk()) {
43
- expect(missing.value).toBeUndefined();
44
- }
41
+ const missingValue = missing.unwrapOr(undefined);
42
+ expect(missingValue).toBeUndefined();
45
43
  });
46
44
  it("overwrites an existing codemod when adding with the same id", async () => {
47
45
  const registry = new LocalCodemodRegistry();
@@ -51,9 +49,8 @@ describe("LocalCodemodRegistry", () => {
51
49
  registry.add(second);
52
50
  const byId = await registry.getById("a");
53
51
  expect(byId.isOk()).toBe(true);
54
- if (byId.isOk()) {
55
- expect(byId.value).toBe(second);
56
- expect(byId.value).not.toBe(first);
57
- }
52
+ const byIdValue = byId.unwrapOr(undefined);
53
+ expect(byIdValue).toBe(second);
54
+ expect(byIdValue).not.toBe(first);
58
55
  });
59
56
  });
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for authorizeCloudAccounts in the init command.
3
+ */
4
+ export {};
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Tests for authorizeCloudAccounts in the init command.
3
+ */
4
+ import { errAsync, okAsync } from "neverthrow";
5
+ import { describe, expect, it } from "vitest";
6
+ import { mock } from "vitest-mock-extended";
7
+ import { AuthorizationError, AuthorizationResult, IdentityAlreadyExistsError, } from "../../../../domain/authorization.js";
8
+ import { authorizeCloudAccounts } from "../init.js";
9
+ const makeEnvPayload = (overrides = {}) => ({
10
+ env: {
11
+ cloudAccounts: [
12
+ {
13
+ csp: "azure",
14
+ defaultLocation: "italynorth",
15
+ displayName: "DEV-FooBar",
16
+ id: "sub-123",
17
+ },
18
+ ],
19
+ name: "dev",
20
+ prefix: "dx",
21
+ },
22
+ github: { owner: "pagopa", repo: "test-repo" },
23
+ tags: { BusinessUnit: "BU", CostCenter: "TS000", ManagementTeam: "MT" },
24
+ workspace: { domain: "" },
25
+ ...overrides,
26
+ });
27
+ describe("authorizeCloudAccounts", () => {
28
+ it("returns empty array when init is undefined (env already initialized)", async () => {
29
+ const authService = mock();
30
+ const envPayload = makeEnvPayload({ init: undefined });
31
+ const result = await authorizeCloudAccounts(authService)(envPayload);
32
+ expect(result.isOk()).toBe(true);
33
+ expect(result._unsafeUnwrap()).toEqual([]);
34
+ expect(authService.requestAuthorization).not.toHaveBeenCalled();
35
+ });
36
+ it("returns empty array when cloudAccountsToInitialize is empty", async () => {
37
+ const authService = mock();
38
+ const envPayload = makeEnvPayload({
39
+ init: { cloudAccountsToInitialize: [] },
40
+ });
41
+ const result = await authorizeCloudAccounts(authService)(envPayload);
42
+ expect(result.isOk()).toBe(true);
43
+ expect(result._unsafeUnwrap()).toEqual([]);
44
+ });
45
+ it("requests authorization for each initialized account", async () => {
46
+ const authService = mock();
47
+ const expectedPr = new AuthorizationResult("https://github.com/pagopa/eng-azure-authorization/pull/42");
48
+ authService.requestAuthorization.mockReturnValue(okAsync(expectedPr));
49
+ const account = {
50
+ csp: "azure",
51
+ defaultLocation: "italynorth",
52
+ displayName: "DEV-FooBar",
53
+ id: "sub-123",
54
+ };
55
+ const envPayload = makeEnvPayload({
56
+ env: {
57
+ cloudAccounts: [account],
58
+ name: "dev",
59
+ prefix: "dx",
60
+ },
61
+ init: { cloudAccountsToInitialize: [account] },
62
+ });
63
+ const result = await authorizeCloudAccounts(authService)(envPayload);
64
+ expect(result.isOk()).toBe(true);
65
+ expect(result._unsafeUnwrap()).toEqual([expectedPr]);
66
+ expect(authService.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
67
+ bootstrapIdentityId: "dx-d-itn-bootstrap-id-01",
68
+ subscriptionName: "DEV-FooBar",
69
+ }));
70
+ });
71
+ it("computes correct identity for westeurope location", async () => {
72
+ const authService = mock();
73
+ authService.requestAuthorization.mockReturnValue(okAsync(new AuthorizationResult("https://example.com/pr/1")));
74
+ const account = {
75
+ csp: "azure",
76
+ defaultLocation: "westeurope",
77
+ displayName: "PROD-Bar",
78
+ id: "sub-456",
79
+ };
80
+ const envPayload = makeEnvPayload({
81
+ env: {
82
+ cloudAccounts: [account],
83
+ name: "prod",
84
+ prefix: "io",
85
+ },
86
+ init: { cloudAccountsToInitialize: [account] },
87
+ });
88
+ const result = await authorizeCloudAccounts(authService)(envPayload);
89
+ expect(result.isOk()).toBe(true);
90
+ expect(authService.requestAuthorization).toHaveBeenCalledWith(expect.objectContaining({
91
+ bootstrapIdentityId: "io-p-weu-bootstrap-id-01",
92
+ subscriptionName: "PROD-Bar",
93
+ }));
94
+ });
95
+ it("skips accounts with unsupported locations", async () => {
96
+ const authService = mock();
97
+ const account = {
98
+ csp: "azure",
99
+ defaultLocation: "eastus",
100
+ displayName: "DEV-Unsupported",
101
+ id: "sub-789",
102
+ };
103
+ const envPayload = makeEnvPayload({
104
+ init: { cloudAccountsToInitialize: [account] },
105
+ });
106
+ const result = await authorizeCloudAccounts(authService)(envPayload);
107
+ expect(result.isOk()).toBe(true);
108
+ expect(result._unsafeUnwrap()).toEqual([]);
109
+ expect(authService.requestAuthorization).not.toHaveBeenCalled();
110
+ });
111
+ it("continues when authorization fails for one account", async () => {
112
+ const authService = mock();
113
+ const account1 = {
114
+ csp: "azure",
115
+ defaultLocation: "italynorth",
116
+ displayName: "DEV-First",
117
+ id: "sub-1",
118
+ };
119
+ const account2 = {
120
+ csp: "azure",
121
+ defaultLocation: "italynorth",
122
+ displayName: "DEV-Second",
123
+ id: "sub-2",
124
+ };
125
+ const expectedPr = new AuthorizationResult("https://example.com/pr/2");
126
+ authService.requestAuthorization
127
+ .mockReturnValueOnce(errAsync(new AuthorizationError("Branch already exists")))
128
+ .mockReturnValueOnce(okAsync(expectedPr));
129
+ const envPayload = makeEnvPayload({
130
+ env: {
131
+ cloudAccounts: [account1, account2],
132
+ name: "dev",
133
+ prefix: "dx",
134
+ },
135
+ init: { cloudAccountsToInitialize: [account1, account2] },
136
+ });
137
+ const result = await authorizeCloudAccounts(authService)(envPayload);
138
+ expect(result.isOk()).toBe(true);
139
+ const prs = result._unsafeUnwrap();
140
+ expect(prs).toHaveLength(1);
141
+ expect(prs[0]).toEqual(expectedPr);
142
+ });
143
+ it("handles IdentityAlreadyExistsError gracefully", async () => {
144
+ const authService = mock();
145
+ const account = {
146
+ csp: "azure",
147
+ defaultLocation: "italynorth",
148
+ displayName: "DEV-Existing",
149
+ id: "sub-exists",
150
+ };
151
+ authService.requestAuthorization.mockReturnValue(errAsync(new IdentityAlreadyExistsError("dx-d-itn-bootstrap-id-01")));
152
+ const envPayload = makeEnvPayload({
153
+ init: { cloudAccountsToInitialize: [account] },
154
+ });
155
+ const result = await authorizeCloudAccounts(authService)(envPayload);
156
+ expect(result.isOk()).toBe(true);
157
+ expect(result._unsafeUnwrap()).toEqual([]);
158
+ });
159
+ });
@@ -1,5 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { ResultAsync } from "neverthrow";
3
+ import type { Payload as EnvironmentPayload } from "../../plop/generators/environment/index.js";
4
+ import { AuthorizationResult, AuthorizationService } from "../../../domain/authorization.js";
3
5
  import { GitHubService } from "../../../domain/github.js";
4
6
  export declare const checkPreconditions: () => ResultAsync<import("execa").Result<{
5
7
  environment: {
@@ -9,8 +11,13 @@ export declare const checkPreconditions: () => ResultAsync<import("execa").Resul
9
11
  };
10
12
  shell: true;
11
13
  }>, Error>;
14
+ /**
15
+ * Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
16
+ */
17
+ export declare const authorizeCloudAccounts: (authorizationService: AuthorizationService) => (envPayload: EnvironmentPayload) => ResultAsync<AuthorizationResult[], never>;
12
18
  type InitCommandDependencies = {
19
+ authorizationService: AuthorizationService;
13
20
  gitHubService: GitHubService;
14
21
  };
15
- export declare const makeInitCommand: ({ gitHubService, }: InitCommandDependencies) => Command;
22
+ export declare const makeInitCommand: ({ authorizationService, gitHubService, }: InitCommandDependencies) => Command;
16
23
  export {};
@@ -6,7 +6,10 @@ import { okAsync, ResultAsync } from "neverthrow";
6
6
  import * as path from "node:path";
7
7
  import { oraPromise } from "ora";
8
8
  import { z } from "zod";
9
+ import { requestAuthorizationInputSchema, } from "../../../domain/authorization.js";
10
+ import { environmentShort } from "../../../domain/environment.js";
9
11
  import { Repository, } from "../../../domain/github.js";
12
+ import { isAzureLocation, locationShort } from "../../azure/locations.js";
10
13
  import { tf$ } from "../../execa/terraform.js";
11
14
  import { getPlopInstance, runDeploymentEnvironmentGenerator, runMonorepoGenerator, } from "../../plop/index.js";
12
15
  import { exitWithError } from "../index.js";
@@ -16,7 +19,7 @@ const withSpinner = (text, successText, failText, promise) => ResultAsync.fromPr
16
19
  text,
17
20
  }), (cause) => new Error(failText, { cause }));
18
21
  const displaySummary = (initResult) => {
19
- const { pr, repository } = initResult;
22
+ const { authorizationPrs, pr, repository } = initResult;
20
23
  console.log(chalk.green.bold("\nWorkspace created successfully!"));
21
24
  if (repository) {
22
25
  console.log(`- Name: ${chalk.cyan(repository.name)}`);
@@ -26,9 +29,16 @@ const displaySummary = (initResult) => {
26
29
  console.log(chalk.yellow(`\n⚠️ GitHub repository may not have been created automatically.`));
27
30
  }
28
31
  if (pr) {
32
+ let step = 1;
29
33
  console.log(chalk.green.bold("\nNext Steps:"));
30
- console.log(`1. Review the Pull Request in the GitHub repository: ${chalk.underline(pr.url)}`);
31
- console.log(`2. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
34
+ console.log(`${step++}. Review the Pull Request in the GitHub repository: ${chalk.underline(pr.url)}`);
35
+ if (authorizationPrs.length > 0) {
36
+ console.log(`${step++}. Review the Azure authorization Pull Request(s):`);
37
+ for (const authPr of authorizationPrs) {
38
+ console.log(` - ${chalk.underline(authPr.url)}`);
39
+ }
40
+ }
41
+ console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
32
42
  }
33
43
  else {
34
44
  console.log(chalk.yellow(`\n⚠️ There was an error during Pull Request creation.`));
@@ -113,7 +123,46 @@ const handleGeneratorError = (err) => {
113
123
  }
114
124
  return new Error("Failed to run the generator");
115
125
  };
116
- export const makeInitCommand = ({ gitHubService, }) => new Command()
126
+ /**
127
+ * Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
128
+ */
129
+ export const authorizeCloudAccounts = (authorizationService) => (envPayload) => {
130
+ const accountsToInitialize = envPayload.init?.cloudAccountsToInitialize ?? [];
131
+ if (accountsToInitialize.length === 0) {
132
+ return okAsync([]);
133
+ }
134
+ const logger = getLogger(["dx-cli", "init"]);
135
+ const { name, prefix } = envPayload.env;
136
+ const envShort = environmentShort[name];
137
+ const requestAll = async () => {
138
+ const results = [];
139
+ for (const account of accountsToInitialize) {
140
+ if (!isAzureLocation(account.defaultLocation)) {
141
+ logger.warn("Skipping authorization for {account}: unsupported location", { account: account.displayName });
142
+ continue;
143
+ }
144
+ const locShort = locationShort[account.defaultLocation];
145
+ const input = requestAuthorizationInputSchema.safeParse({
146
+ bootstrapIdentityId: `${prefix}-${envShort}-${locShort}-bootstrap-id-01`,
147
+ subscriptionName: account.displayName,
148
+ });
149
+ if (!input.success) {
150
+ logger.warn("Skipping authorization for {account}: invalid input", {
151
+ account: account.displayName,
152
+ });
153
+ continue;
154
+ }
155
+ const result = await authorizationService.requestAuthorization(input.data);
156
+ result.match((authResult) => results.push(authResult), (error) => logger.warn("Authorization request failed for {account}: {error}", {
157
+ account: account.displayName,
158
+ error: error.message,
159
+ }));
160
+ }
161
+ return results;
162
+ };
163
+ return ResultAsync.fromPromise(requestAll(), () => []).orElse(() => okAsync([]));
164
+ };
165
+ export const makeInitCommand = ({ authorizationService, gitHubService, }) => new Command()
117
166
  .name("init")
118
167
  .description("Initialize a new DX workspace")
119
168
  .action(async function () {
@@ -127,11 +176,11 @@ export const makeInitCommand = ({ gitHubService, }) => new Command()
127
176
  process.chdir(payload.repoName);
128
177
  console.log(chalk.blue.bold("\nCloud Environment"));
129
178
  })
130
- .andThen((payload) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, {
131
- owner: payload.repoOwner,
132
- repo: payload.repoName,
133
- }), handleGeneratorError).map(() => payload)))
179
+ .andThen((monorepoPayload) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, {
180
+ owner: monorepoPayload.repoOwner,
181
+ repo: monorepoPayload.repoName,
182
+ }), handleGeneratorError).map((envPayload) => ({ envPayload, monorepoPayload }))))
134
183
  .andTee(() => console.log()) // Print a new line before the gh repo creation logs
135
- .andThen((payload) => handleNewGitHubRepository(gitHubService)(payload))
184
+ .andThen(({ envPayload, monorepoPayload }) => handleNewGitHubRepository(gitHubService)(monorepoPayload).andThen((repoPr) => authorizeCloudAccounts(authorizationService)(envPayload).map((authorizationPrs) => ({ ...repoPr, authorizationPrs }))))
136
185
  .match(displaySummary, exitWithError(this));
137
186
  });
@@ -1,6 +1,7 @@
1
1
  import type { NodePlopAPI } from "plop";
2
2
  import { GitHubRepo } from "../../domain/github-repo.js";
3
3
  import { GitHubService } from "../../domain/github.js";
4
+ import { Payload as EnvironmentPayload } from "../plop/generators/environment/index.js";
4
5
  import { Payload as MonorepoPayload } from "../plop/generators/monorepo/index.js";
5
6
  export declare const setMonorepoGenerator: (plop: NodePlopAPI) => void;
6
7
  export declare const getPlopInstance: () => Promise<NodePlopAPI>;
@@ -13,7 +14,7 @@ export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: Gi
13
14
  * uses the explicitly passed repository. When omitted (by add command),
14
15
  * the generator infers it from the local git context.
15
16
  */
16
- export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<void>;
17
+ export declare const runDeploymentEnvironmentGenerator: (plop: NodePlopAPI, github?: GitHubRepo) => Promise<EnvironmentPayload>;
17
18
  /**
18
19
  * Configure the deployment environment generator
19
20
  *
@@ -7,7 +7,7 @@ import { oraPromise } from "ora";
7
7
  import { RepositoryNotFoundError } from "../../domain/github.js";
8
8
  import { AzureSubscriptionRepository } from "../azure/cloud-account-repository.js";
9
9
  import { AzureCloudAccountService } from "../azure/cloud-account-service.js";
10
- import createDeploymentEnvironmentGenerator, { PLOP_ENVIRONMENT_GENERATOR_NAME, } from "../plop/generators/environment/index.js";
10
+ import createDeploymentEnvironmentGenerator, { payloadSchema as environmentPayloadSchema, PLOP_ENVIRONMENT_GENERATOR_NAME, } from "../plop/generators/environment/index.js";
11
11
  import createMonorepoGenerator, { payloadSchema as monorepoPayloadSchema, PLOP_MONOREPO_GENERATOR_NAME, } from "../plop/generators/monorepo/index.js";
12
12
  export const setMonorepoGenerator = (plop) => {
13
13
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
@@ -65,12 +65,14 @@ export const runMonorepoGenerator = async (plop, githubService) => {
65
65
  export const runDeploymentEnvironmentGenerator = async (plop, github) => {
66
66
  setDeploymentEnvironmentGenerator(plop, github);
67
67
  const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
68
- const payload = await generator.runPrompts();
68
+ const answers = await generator.runPrompts();
69
+ const payload = environmentPayloadSchema.parse(answers);
69
70
  await oraPromise(runActions(generator, payload), {
70
71
  failText: "Failed to create deployment environment",
71
72
  successText: "Environment created successfully!",
72
73
  text: "Creating environment...",
73
74
  });
75
+ return payload;
74
76
  };
75
77
  /**
76
78
  * Configure the deployment environment generator
@@ -31,10 +31,9 @@ describe("applyCodemodById", () => {
31
31
  };
32
32
  const result = await applyCodemodById(registry, info)("missing-id");
33
33
  expect(result.isErr()).toBe(true);
34
- if (result.isErr()) {
35
- expect(result.error).toBeInstanceOf(Error);
36
- expect(result.error.message).toBe("Codemod with id missing-id not found");
37
- }
34
+ const value = result.isErr() ? result.error : null;
35
+ expect(value).toBeInstanceOf(Error);
36
+ expect(value?.message).toBe("Codemod with id missing-id not found");
38
37
  });
39
38
  it("propagates getById errors", async () => {
40
39
  const registryError = new Error("registry failed");
@@ -46,9 +45,9 @@ describe("applyCodemodById", () => {
46
45
  };
47
46
  const result = await applyCodemodById(registry, info)("foo");
48
47
  expect(result.isErr()).toBe(true);
49
- if (result.isErr()) {
50
- expect(result.error.message).toContain(registryError.message);
51
- }
48
+ const value = result.isErr() ? result.error : null;
49
+ expect(value).toBeInstanceOf(Error);
50
+ expect(value?.message).toContain(registryError.message);
52
51
  });
53
52
  it("propagates apply errors", async () => {
54
53
  const applyError = new Error("apply failed");
@@ -65,9 +64,8 @@ describe("applyCodemodById", () => {
65
64
  };
66
65
  const result = await applyCodemodById(registry, info)("foo");
67
66
  expect(result.isErr()).toBe(true);
68
- if (result.isErr()) {
69
- expect(result.error).toBeInstanceOf(Error);
70
- expect(result.error.message).toContain(applyError.message);
71
- }
67
+ const value = result.isErr() ? result.error : null;
68
+ expect(value).toBeInstanceOf(Error);
69
+ expect(value?.message).toContain(applyError.message);
72
70
  });
73
71
  });
@@ -31,8 +31,7 @@ describe("listCodemods", () => {
31
31
  };
32
32
  const result = await listCodemods(registry)();
33
33
  expect(result.isErr()).toBe(true);
34
- if (result.isErr()) {
35
- expect(result.error).toBe(error);
36
- }
34
+ const value = result.isErr() ? result.error : null;
35
+ expect(value).toBe(error);
37
36
  });
38
37
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.18.8",
3
+ "version": "0.18.10",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "semver": "^7.7.4",
48
48
  "yaml": "^2.8.2",
49
49
  "zod": "^4.3.6",
50
- "@pagopa/dx-savemoney": "^0.2.0"
50
+ "@pagopa/dx-savemoney": "^0.2.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@tsconfig/node24": "24.0.4",
@@ -55,14 +55,14 @@
55
55
  "@types/node": "^22.19.15",
56
56
  "@types/semver": "^7.7.1",
57
57
  "@vitest/coverage-v8": "^3.2.4",
58
- "eslint": "^9.39.2",
58
+ "eslint": "^10.1.0",
59
59
  "memfs": "^4.51.1",
60
60
  "plop": "^4.0.5",
61
61
  "prettier": "3.8.1",
62
62
  "typescript": "~5.9.3",
63
63
  "vitest": "^3.2.4",
64
64
  "vitest-mock-extended": "^3.1.0",
65
- "@pagopa/eslint-config": "^5.1.2"
65
+ "@pagopa/eslint-config": "^6.0.0"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=22.0.0"