@pagopa/dx-cli 0.21.1 → 0.21.2

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.
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for confirmGitHubRepoCreation in the init command.
3
+ */
4
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tests for confirmGitHubRepoCreation in the init command.
3
+ */
4
+ import inquirer from "inquirer";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { confirmGitHubRepoCreation } from "../init.js";
7
+ vi.mock("inquirer");
8
+ const makePayload = (overrides = {}) => ({
9
+ repoDescription: "A test repo",
10
+ repoName: "test-repo",
11
+ repoOwner: "pagopa",
12
+ ...overrides,
13
+ });
14
+ describe("confirmGitHubRepoCreation", () => {
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+ it("returns true when the user confirms", async () => {
19
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
20
+ const result = await confirmGitHubRepoCreation(makePayload());
21
+ expect(result.isOk()).toBe(true);
22
+ expect(result._unsafeUnwrap()).toBe(true);
23
+ });
24
+ it("returns false when the user declines", async () => {
25
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: false });
26
+ const result = await confirmGitHubRepoCreation(makePayload());
27
+ expect(result.isOk()).toBe(true);
28
+ expect(result._unsafeUnwrap()).toBe(false);
29
+ });
30
+ it("prompts with the correct repository name and owner", async () => {
31
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
32
+ const payload = makePayload({ repoName: "my-repo", repoOwner: "my-org" });
33
+ await confirmGitHubRepoCreation(payload);
34
+ expect(inquirer.prompt).toHaveBeenCalledWith(expect.objectContaining({
35
+ message: expect.stringContaining("my-org/my-repo"),
36
+ type: "confirm",
37
+ }));
38
+ });
39
+ it("returns an error result when the prompt rejects", async () => {
40
+ const cause = new Error("non-interactive TTY");
41
+ vi.mocked(inquirer.prompt).mockRejectedValue(cause);
42
+ const result = await confirmGitHubRepoCreation(makePayload());
43
+ expect(result.isErr()).toBe(true);
44
+ const err = result._unsafeUnwrapErr();
45
+ expect(err).toBeInstanceOf(Error);
46
+ expect(err.cause).toBe(cause);
47
+ });
48
+ });
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { ResultAsync } from "neverthrow";
3
3
  import { GitHubService } from "../../../domain/github.js";
4
+ import { Payload as MonorepoPayload } from "../../plop/generators/monorepo/index.js";
4
5
  export declare const checkInitPreconditions: () => ResultAsync<import("execa").Result<{
5
6
  environment: {
6
7
  NO_COLOR: string;
@@ -17,6 +18,7 @@ export declare const checkAddEnvironmentPreconditions: () => ResultAsync<import(
17
18
  };
18
19
  shell: true;
19
20
  }>, Error>;
21
+ export declare const confirmGitHubRepoCreation: (payload: MonorepoPayload) => ResultAsync<boolean, Error>;
20
22
  type InitCommandDependencies = {
21
23
  gitHubService: GitHubService;
22
24
  };
@@ -2,6 +2,7 @@ import { getLogger } from "@logtape/logtape";
2
2
  import chalk from "chalk";
3
3
  import { Command } from "commander";
4
4
  import { $, ExecaError } from "execa";
5
+ import inquirer from "inquirer";
5
6
  import { okAsync, ResultAsync } from "neverthrow";
6
7
  import * as path from "node:path";
7
8
  import { oraPromise } from "ora";
@@ -10,13 +11,28 @@ import { Repository, } from "../../../domain/github.js";
10
11
  import { tf$ } from "../../execa/terraform.js";
11
12
  import { getPlopInstance, runMonorepoGenerator } from "../../plop/index.js";
12
13
  import { exitWithError } from "../index.js";
14
+ const isGitHubRepoCreationSkipped = (input) => "gitHubRepoCreationSkipped" in input;
13
15
  const withSpinner = (text, successText, failText, promise) => ResultAsync.fromPromise(oraPromise(promise, {
14
16
  failText,
15
17
  successText,
16
18
  text,
17
19
  }), (cause) => new Error(failText, { cause }));
18
- const displaySummary = (initResult) => {
19
- const { pr, repository } = initResult;
20
+ const displaySummary = (input) => {
21
+ const docsUrl = "https://dx.pagopa.it/getting-started";
22
+ if (isGitHubRepoCreationSkipped(input)) {
23
+ const { payload } = input;
24
+ console.log(chalk.yellow.bold("\nGitHub repository creation skipped."));
25
+ console.log(`The workspace files have been scaffolded in ${chalk.cyan(payload.repoName + "/")}.`);
26
+ console.log(chalk.bold("\nTo finish the setup manually:"));
27
+ let step = 1;
28
+ console.log(`${step++}. Create the GitHub repository by applying the Terraform config scaffolded at ${chalk.cyan(`${payload.repoName}/infra/repository`)}:`);
29
+ console.log(` ${chalk.cyan(`cd ${payload.repoName}/infra/repository && terraform init && terraform apply`)}`);
30
+ console.log(`${step++}. Push the scaffolded code to the newly created repository:`);
31
+ console.log(` ${chalk.cyan(`cd ${payload.repoName} && git init && git remote add origin <url> && git push`)}`);
32
+ console.log(`${step}. Visit ${chalk.underline(docsUrl)} to deploy your first project\n`);
33
+ return;
34
+ }
35
+ const { pr, repository } = input;
20
36
  console.log(chalk.green.bold("\nWorkspace created successfully!"));
21
37
  if (repository) {
22
38
  console.log(`- Name: ${chalk.cyan(repository.name)}`);
@@ -29,7 +45,7 @@ const displaySummary = (initResult) => {
29
45
  let step = 1;
30
46
  console.log(chalk.green.bold("\nNext Steps:"));
31
47
  console.log(`${step++}. Review the Pull Request in the GitHub repository: ${chalk.underline(pr.url)}`);
32
- console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
48
+ console.log(`${step}. Visit ${chalk.underline(docsUrl)} to deploy your first project\n`);
33
49
  }
34
50
  else {
35
51
  console.log(chalk.yellow(`\n⚠️ There was an error during Pull Request creation.`));
@@ -118,6 +134,14 @@ const handleGeneratorError = (err) => {
118
134
  }
119
135
  return new Error("Failed to run the generator", { cause: err });
120
136
  };
137
+ export const confirmGitHubRepoCreation = (payload) => ResultAsync.fromPromise(inquirer
138
+ .prompt({
139
+ default: true,
140
+ message: `The project is created on ${chalk.green(payload.repoName)}. Would you like to publish it to GitHub at ${chalk.green(`${payload.repoOwner}/${payload.repoName}`)} now?`,
141
+ name: "confirm",
142
+ type: "confirm",
143
+ })
144
+ .then(({ confirm }) => confirm), (cause) => new Error("Failed to read GitHub publish confirmation", { cause }));
121
145
  export const makeInitCommand = ({ gitHubService, }) => new Command()
122
146
  .name("init")
123
147
  .description("Initialize a new DX workspace")
@@ -128,6 +152,8 @@ export const makeInitCommand = ({ gitHubService, }) => new Command()
128
152
  .andTee((payload) => {
129
153
  process.chdir(payload.repoName);
130
154
  })
131
- .andThen((payload) => handleNewGitHubRepository(gitHubService)(payload))
155
+ .andThen((payload) => confirmGitHubRepoCreation(payload).andThen((confirmed) => confirmed
156
+ ? handleNewGitHubRepository(gitHubService)(payload)
157
+ : okAsync({ gitHubRepoCreationSkipped: true, payload })))
132
158
  .match(displaySummary, exitWithError(this));
133
159
  });
@@ -1,6 +1,6 @@
1
1
  // Tests for workspaceSchema transforms (lowercase and trim on domain).
2
2
  import { describe, expect, it } from "vitest";
3
- import { workspaceSchema } from "../prompts.js";
3
+ import { formatInitializationDetails, workspaceSchema } from "../prompts.js";
4
4
  describe("workspaceSchema — domain transforms", () => {
5
5
  it("lowercases an uppercase domain", () => {
6
6
  const result = workspaceSchema.safeParse({ domain: "API" });
@@ -23,3 +23,63 @@ describe("workspaceSchema — domain transforms", () => {
23
23
  expect(result.success && result.data.domain).toBe("");
24
24
  });
25
25
  });
26
+ describe("formatInitializationDetails", () => {
27
+ const account = (overrides = {}) => ({
28
+ csp: "azure",
29
+ defaultLocation: "italynorth",
30
+ displayName: "DEV-FooBar",
31
+ id: "sub-123",
32
+ ...overrides,
33
+ });
34
+ const notInitializedStatus = (issues) => ({
35
+ initialized: false,
36
+ issues,
37
+ });
38
+ it("lists each uninitialized cloud account by displayName", () => {
39
+ const status = notInitializedStatus([
40
+ {
41
+ cloudAccount: account({ displayName: "DEV-A" }),
42
+ type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
43
+ },
44
+ {
45
+ cloudAccount: account({ displayName: "DEV-B" }),
46
+ type: "CLOUD_ACCOUNT_NOT_INITIALIZED",
47
+ },
48
+ ]);
49
+ const output = formatInitializationDetails(status);
50
+ expect(output).toContain('Azure subscription "DEV-A"');
51
+ expect(output).toContain('Azure subscription "DEV-B"');
52
+ expect(output).toContain("managed identity");
53
+ expect(output).toContain("OIDC");
54
+ expect(output).toContain("ARM_CLIENT_ID");
55
+ expect(output).toContain("ARM_SUBSCRIPTION_ID");
56
+ expect(output).not.toContain("ARM_TENANT_ID");
57
+ expect(output).toContain("Key Vault");
58
+ });
59
+ it("includes the Terraform backend section when MISSING_REMOTE_BACKEND issue is present", () => {
60
+ const status = notInitializedStatus([
61
+ { cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
62
+ { type: "MISSING_REMOTE_BACKEND" },
63
+ ]);
64
+ const output = formatInitializationDetails(status);
65
+ expect(output).toContain("Terraform remote backend");
66
+ expect(output).toContain("Storage Account");
67
+ });
68
+ it("omits the Terraform backend section when no MISSING_REMOTE_BACKEND issue is present", () => {
69
+ const status = notInitializedStatus([
70
+ { cloudAccount: account(), type: "CLOUD_ACCOUNT_NOT_INITIALIZED" },
71
+ ]);
72
+ const output = formatInitializationDetails(status);
73
+ expect(output).not.toContain("Terraform remote backend");
74
+ });
75
+ it("omits the cloud account section when no CLOUD_ACCOUNT_NOT_INITIALIZED issues are present", () => {
76
+ const status = notInitializedStatus([{ type: "MISSING_REMOTE_BACKEND" }]);
77
+ const output = formatInitializationDetails(status);
78
+ expect(output).not.toContain("Azure subscription");
79
+ expect(output).toContain("Terraform remote backend");
80
+ });
81
+ it("returns an empty string when there are no relevant issues", () => {
82
+ const status = notInitializedStatus([]);
83
+ expect(formatInitializationDetails(status)).toBe("");
84
+ });
85
+ });
@@ -79,4 +79,13 @@ export declare const getCloudAccountToInitialize: (initStatus: EnvironmentInitSt
79
79
  displayName: string;
80
80
  id: string;
81
81
  }[];
82
+ /**
83
+ * Build a human-readable description of the resources that will be created
84
+ * when initializing an environment, so users see the side effects before
85
+ * confirming. The exact resource names are intentionally omitted to avoid
86
+ * coupling the prompt copy to internal naming conventions.
87
+ */
88
+ export declare const formatInitializationDetails: (initStatus: EnvironmentInitStatus & {
89
+ initialized: false;
90
+ }) => string;
82
91
  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
  });
@@ -198,4 +200,40 @@ export const getCloudLocationChoices = (regions) => regions.map((r) => ({ name:
198
200
  export const getCloudAccountToInitialize = (initStatus) => initStatus.issues
199
201
  .filter((issue) => issue.type === "CLOUD_ACCOUNT_NOT_INITIALIZED")
200
202
  .map((issue) => issue.cloudAccount);
203
+ /**
204
+ * Build a human-readable description of the resources that will be created
205
+ * when initializing an environment, so users see the side effects before
206
+ * confirming. The exact resource names are intentionally omitted to avoid
207
+ * coupling the prompt copy to internal naming conventions.
208
+ */
209
+ export const formatInitializationDetails = (initStatus) => {
210
+ const accountsToInit = getCloudAccountToInitialize(initStatus);
211
+ const missingBackend = initStatus.issues.some((issue) => issue.type === "MISSING_REMOTE_BACKEND");
212
+ const sections = [];
213
+ for (const account of accountsToInit) {
214
+ sections.push([
215
+ chalk.bold.cyan(` Azure subscription "${account.displayName}":`),
216
+ ` • Bootstrap resource group and managed identity with subscription-scoped roles`,
217
+ ` • GitHub OIDC federated identity credential`,
218
+ ` • GitHub environment secrets (ARM_CLIENT_ID, ARM_SUBSCRIPTION_ID)`,
219
+ ` • Common Key Vault storing the GitHub runner app credentials`,
220
+ ].join("\n"));
221
+ }
222
+ if (missingBackend) {
223
+ sections.push([
224
+ chalk.bold.cyan(` Terraform remote backend:`),
225
+ ` • Azure resource group and Storage Account for the Terraform state`,
226
+ ].join("\n"));
227
+ }
228
+ if (sections.length === 0) {
229
+ return "";
230
+ }
231
+ return [
232
+ "",
233
+ chalk.bold("The following resources will be created:"),
234
+ "",
235
+ ...sections,
236
+ "",
237
+ ].join("\n");
238
+ };
201
239
  export default prompts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {