@pagopa/dx-cli 0.20.2 → 0.21.1

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 (27) hide show
  1. package/README.md +12 -1
  2. package/bin/index.js +0 -20
  3. package/dist/adapters/azure/__tests__/cloud-account-service.test.js +48 -2
  4. package/dist/adapters/azure/cloud-account-service.js +67 -24
  5. package/dist/adapters/commander/__tests__/error-reporting.test.d.ts +1 -0
  6. package/dist/adapters/commander/__tests__/error-reporting.test.js +63 -0
  7. package/dist/adapters/commander/__tests__/exit-with-error.test.d.ts +1 -0
  8. package/dist/adapters/commander/__tests__/exit-with-error.test.js +92 -0
  9. package/dist/adapters/commander/commands/__tests__/preconditions.test.d.ts +1 -0
  10. package/dist/adapters/commander/commands/__tests__/preconditions.test.js +32 -0
  11. package/dist/adapters/commander/commands/add.js +8 -5
  12. package/dist/adapters/commander/commands/codemod.js +3 -2
  13. package/dist/adapters/commander/commands/init.d.ts +9 -1
  14. package/dist/adapters/commander/commands/init.js +5 -4
  15. package/dist/adapters/commander/commands/savemoney.js +6 -3
  16. package/dist/adapters/commander/error-reporting.d.ts +10 -0
  17. package/dist/adapters/commander/error-reporting.js +68 -0
  18. package/dist/adapters/commander/index.d.ts +17 -1
  19. package/dist/adapters/commander/index.js +23 -2
  20. package/dist/adapters/plop/__tests__/run-actions.test.d.ts +1 -0
  21. package/dist/adapters/plop/__tests__/run-actions.test.js +68 -0
  22. package/dist/adapters/plop/index.d.ts +3 -1
  23. package/dist/adapters/plop/index.js +16 -8
  24. package/dist/index.js +36 -0
  25. package/package.json +10 -10
  26. package/templates/environment/workflow/_release-terraform-apply-bootstrapper-{{env.name}}.yaml.hbs +1 -0
  27. package/templates/monorepo/.prettierignore +2 -1
package/README.md CHANGED
@@ -184,9 +184,10 @@ dx savemoney [options]
184
184
  | `--format` | `-f` | Report format: `table`, `json`, `detailed-json`, or `lint`. | `table` |
185
185
  | `--days` | `-d` | Metric analysis period in days (overrides config file). | `30` |
186
186
  | `--location` | `-l` | Preferred Azure location for resources (overrides config file). | `italynorth` |
187
- | `--verbose` | `-v` | Enable verbose mode with detailed logging for each resource analyzed. | `false` |
188
187
  | `--tags` | `-t` | Filter resources by tags (`key=value key2=value2`). Only resources matching **all** specified tags are analyzed (variadic: space-separated). | N/A |
189
188
 
189
+ > `--verbose` / `-v` is inherited from the root command. See [Global Options](#global-options).
190
+
190
191
  **Example usage:**
191
192
 
192
193
  ```bash
@@ -245,9 +246,19 @@ azure:
245
246
 
246
247
  ### Global Options
247
248
 
249
+ These options are available on every subcommand:
250
+
251
+ - `--verbose, -v`: Enable verbose output. Lowers the log level to `debug` so that detailed progress information is emitted, and — when a command fails — prints the full error details, including the underlying `cause` chain and stack trace, instead of only the top-level message. Defaults to `false`.
248
252
  - `--version, -V`: Display version number
249
253
  - `--help, -h`: Display help information
250
254
 
255
+ **Example:**
256
+
257
+ ```bash
258
+ # Re-run a failing command with full diagnostics
259
+ dx --verbose init project
260
+ ```
261
+
251
262
  ---
252
263
 
253
264
  <div align="center">
package/bin/index.js CHANGED
@@ -1,26 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { configure, getConsoleSink } from "@logtape/logtape";
4
3
  import { runCli } from "../dist/index.js";
5
4
  import packageJson from "../package.json" with { type: "json" };
6
5
 
7
- await configure({
8
- loggers: [
9
- { category: ["dx-cli"], lowestLevel: "info", sinks: ["console"] },
10
- { category: ["savemoney"], lowestLevel: "debug", sinks: ["console"] },
11
- { category: ["json"], lowestLevel: "info", sinks: ["rawJson"] },
12
- {
13
- category: ["logtape", "meta"],
14
- lowestLevel: "warning",
15
- sinks: ["console"],
16
- },
17
- ],
18
- sinks: {
19
- console: getConsoleSink(),
20
- rawJson(record) {
21
- console.log(record.rawMessage);
22
- },
23
- },
24
- });
25
-
26
6
  runCli(packageJson.version).catch((error) => console.error(error.message));
@@ -241,7 +241,10 @@ describe("isInitialized", () => {
241
241
  });
242
242
  });
243
243
  describe("initialize", () => {
244
- test("assigns bootstrap roles to the bootstrap identity", async ({ cloudAccountService, }) => {
244
+ test("assigns bootstrap roles and creates bootstrap environment secrets", async ({ cloudAccountService, }) => {
245
+ const createOrUpdateEnvironmentSecret = vi
246
+ .fn()
247
+ .mockResolvedValue(undefined);
245
248
  await cloudAccountService.initialize({
246
249
  csp: "azure",
247
250
  defaultLocation: "italynorth",
@@ -259,7 +262,7 @@ describe("initialize", () => {
259
262
  repo: "dx",
260
263
  }, {
261
264
  createBranch: vi.fn(),
262
- createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
265
+ createOrUpdateEnvironmentSecret,
263
266
  createPullRequest: vi.fn(),
264
267
  getFileContent: vi.fn(),
265
268
  getRepository: vi.fn(),
@@ -286,5 +289,48 @@ describe("initialize", () => {
286
289
  issuer: "https://token.actions.githubusercontent.com",
287
290
  subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
288
291
  });
292
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledTimes(6);
293
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
294
+ environmentName: "bootstrapper-dev-cd",
295
+ owner: "pagopa",
296
+ repo: "dx",
297
+ secretName: "ARM_CLIENT_ID",
298
+ secretValue: "client-1",
299
+ });
300
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
301
+ environmentName: "bootstrapper-dev-cd",
302
+ owner: "pagopa",
303
+ repo: "dx",
304
+ secretName: "ARM_TENANT_ID",
305
+ secretValue: "tenant-1",
306
+ });
307
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
308
+ environmentName: "bootstrapper-dev-cd",
309
+ owner: "pagopa",
310
+ repo: "dx",
311
+ secretName: "ARM_SUBSCRIPTION_ID",
312
+ secretValue: "sub-1",
313
+ });
314
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
315
+ environmentName: "bootstrapper-dev-cd",
316
+ owner: "pagopa",
317
+ repo: "dx",
318
+ secretName: "GH_APP_ID",
319
+ secretValue: "app-id",
320
+ });
321
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
322
+ environmentName: "bootstrapper-dev-cd",
323
+ owner: "pagopa",
324
+ repo: "dx",
325
+ secretName: "GH_APP_INSTALLATION_ID",
326
+ secretValue: "installation-id",
327
+ });
328
+ expect(createOrUpdateEnvironmentSecret).toHaveBeenCalledWith({
329
+ environmentName: "bootstrapper-dev-cd",
330
+ owner: "pagopa",
331
+ repo: "dx",
332
+ secretName: "GH_APP_KEY",
333
+ secretValue: "private-key",
334
+ });
289
335
  });
290
336
  });
@@ -169,6 +169,7 @@ export class AzureCloudAccountService {
169
169
  const parameters = {
170
170
  location: cloudAccount.defaultLocation,
171
171
  tags: {
172
+ CreatedBy: "DX CLI",
172
173
  Environment: name,
173
174
  ...tags,
174
175
  },
@@ -185,6 +186,10 @@ export class AzureCloudAccountService {
185
186
  const identityClientId = identity.clientId;
186
187
  logger.debug("Created identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
187
188
  const authorizationManagementClient = new AuthorizationManagementClient(this.#credential, cloudAccount.id);
189
+ const subscriptionClient = new SubscriptionClient(this.#credential);
190
+ const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
191
+ assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
192
+ const tenantId = subscription.tenantId;
188
193
  const subscriptionScope = `/subscriptions/${cloudAccount.id}`;
189
194
  // Grant the bootstrap identity the Azure permissions it needs to operate autonomously in the bootstrap workflow.
190
195
  await Promise.all(bootstrapIdentityRoleDefinitionIds.map((roleDefinitionId) => authorizationManagementClient.roleAssignments.create(subscriptionScope, this.#createRoleAssignmentName(subscriptionScope, identityPrincipalId, roleDefinitionId), {
@@ -205,25 +210,14 @@ export class AzureCloudAccountService {
205
210
  identityName,
206
211
  subscriptionId: cloudAccount.id,
207
212
  });
208
- // These secrets let the GitHub workflow target the bootstrap identity and subscription without extra setup.
209
- await Promise.all([
210
- gitHubService.createOrUpdateEnvironmentSecret({
211
- environmentName: githubEnvironmentName,
212
- owner: github.owner,
213
- repo: github.repo,
214
- secretName: "ARM_CLIENT_ID",
215
- secretValue: identityClientId,
216
- }),
217
- gitHubService.createOrUpdateEnvironmentSecret({
218
- environmentName: githubEnvironmentName,
219
- owner: github.owner,
220
- repo: github.repo,
221
- secretName: "ARM_SUBSCRIPTION_ID",
222
- secretValue: cloudAccount.id,
223
- }),
224
- ]);
225
- logger.debug("Set GitHub environment secrets for {environmentName}", {
226
- environmentName: githubEnvironmentName,
213
+ await this.#storeBootstrapperEnvironmentSecrets({
214
+ cloudAccountId: cloudAccount.id,
215
+ github,
216
+ githubEnvironmentName,
217
+ gitHubService,
218
+ identityClientId,
219
+ runnerAppCredentials,
220
+ tenantId,
227
221
  });
228
222
  const keyVaultName = await this.#createCommonKeyVault({
229
223
  cloudAccount,
@@ -233,6 +227,7 @@ export class AzureCloudAccountService {
233
227
  shortEnv: short.env,
234
228
  shortLocation: short.location,
235
229
  tags,
230
+ tenantId,
236
231
  });
237
232
  await this.#storeRunnerAppSecrets({
238
233
  cloudAccountId: cloudAccount.id,
@@ -348,11 +343,8 @@ export class AzureCloudAccountService {
348
343
  }));
349
344
  return results.every(Boolean);
350
345
  }
351
- async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, }) {
346
+ async #createCommonKeyVault({ cloudAccount, name, prefix, resourceGroupName, shortEnv, shortLocation, tags, tenantId, }) {
352
347
  const logger = getLogger(["gen", "env"]);
353
- const subscriptionClient = new SubscriptionClient(this.#credential);
354
- const subscription = await subscriptionClient.subscriptions.get(cloudAccount.id);
355
- assert.ok(subscription.tenantId, "Subscription tenant ID is undefined");
356
348
  const kvClient = new KeyVaultManagementClient(this.#credential, cloudAccount.id);
357
349
  const keyVaultName = `${prefix}-${shortEnv}-${shortLocation}-common-kv-01`;
358
350
  const secretsProtectionEnabled = shortEnv === "p";
@@ -372,7 +364,7 @@ export class AzureCloudAccountService {
372
364
  name: "standard",
373
365
  },
374
366
  softDeleteRetentionInDays: secretsProtectionEnabled ? 14 : 7,
375
- tenantId: subscription.tenantId,
367
+ tenantId,
376
368
  },
377
369
  tags: {
378
370
  Environment: name,
@@ -437,6 +429,57 @@ export class AzureCloudAccountService {
437
429
  }));
438
430
  logger.info("All resource providers registered on subscription {subscriptionId}", { subscriptionId });
439
431
  }
432
+ async #storeBootstrapperEnvironmentSecrets({ cloudAccountId, github, githubEnvironmentName, gitHubService, identityClientId, runnerAppCredentials, tenantId, }) {
433
+ const logger = getLogger(["gen", "env"]);
434
+ await Promise.all([
435
+ gitHubService.createOrUpdateEnvironmentSecret({
436
+ environmentName: githubEnvironmentName,
437
+ owner: github.owner,
438
+ repo: github.repo,
439
+ secretName: "ARM_CLIENT_ID",
440
+ secretValue: identityClientId,
441
+ }),
442
+ gitHubService.createOrUpdateEnvironmentSecret({
443
+ environmentName: githubEnvironmentName,
444
+ owner: github.owner,
445
+ repo: github.repo,
446
+ secretName: "ARM_TENANT_ID",
447
+ secretValue: tenantId,
448
+ }),
449
+ gitHubService.createOrUpdateEnvironmentSecret({
450
+ environmentName: githubEnvironmentName,
451
+ owner: github.owner,
452
+ repo: github.repo,
453
+ secretName: "ARM_SUBSCRIPTION_ID",
454
+ secretValue: cloudAccountId,
455
+ }),
456
+ gitHubService.createOrUpdateEnvironmentSecret({
457
+ environmentName: githubEnvironmentName,
458
+ owner: github.owner,
459
+ repo: github.repo,
460
+ secretName: "GH_APP_ID",
461
+ secretValue: runnerAppCredentials.id,
462
+ }),
463
+ gitHubService.createOrUpdateEnvironmentSecret({
464
+ environmentName: githubEnvironmentName,
465
+ owner: github.owner,
466
+ repo: github.repo,
467
+ secretName: "GH_APP_INSTALLATION_ID",
468
+ secretValue: runnerAppCredentials.installationId,
469
+ }),
470
+ gitHubService.createOrUpdateEnvironmentSecret({
471
+ environmentName: githubEnvironmentName,
472
+ owner: github.owner,
473
+ repo: github.repo,
474
+ secretName: "GH_APP_KEY",
475
+ secretValue: runnerAppCredentials.key.trimEnd(),
476
+ }),
477
+ ]);
478
+ logger.debug("Set GitHub environment secrets for {environmentName}", {
479
+ environmentName: githubEnvironmentName,
480
+ subscriptionId: cloudAccountId,
481
+ });
482
+ }
440
483
  async #storeRunnerAppSecrets({ cloudAccountId, keyVaultName, runnerAppCredentials, }) {
441
484
  const logger = getLogger(["gen", "env"]);
442
485
  const secretClient = new SecretClient(`https://${keyVaultName}.vault.azure.net/`, this.#credential);
@@ -0,0 +1,63 @@
1
+ import { ExecaError } from "execa";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatErrorDetailed, toErrorMessage } from "../error-reporting.js";
4
+ describe("toErrorMessage", () => {
5
+ it("returns the string as-is when given a string", () => {
6
+ expect(toErrorMessage("oops")).toBe("oops");
7
+ });
8
+ it("returns 'Unknown error' for null/undefined", () => {
9
+ expect(toErrorMessage(null)).toBe("Unknown error");
10
+ expect(toErrorMessage(undefined)).toBe("Unknown error");
11
+ });
12
+ it("returns Error.message when given a plain Error", () => {
13
+ expect(toErrorMessage(new Error("boom"))).toBe("boom");
14
+ });
15
+ it("prefers ExecaError.shortMessage over message", () => {
16
+ const execaError = Object.assign(Object.create(ExecaError.prototype), {
17
+ message: "long noisy message with stderr",
18
+ shortMessage: "Command failed: terraform init",
19
+ });
20
+ expect(toErrorMessage(execaError)).toBe("Command failed: terraform init");
21
+ });
22
+ it("flattens AggregateError into a bulleted message", () => {
23
+ const aggregate = new AggregateError([new Error("first"), new Error("second")], "parent");
24
+ expect(toErrorMessage(aggregate)).toBe("parent\n - first\n - second");
25
+ });
26
+ it("extracts `message` property from plain objects when present", () => {
27
+ expect(toErrorMessage({ message: "from object" })).toBe("from object");
28
+ });
29
+ it("falls back to JSON.stringify for objects without message", () => {
30
+ expect(toErrorMessage({ code: 42 })).toBe('{"code":42}');
31
+ });
32
+ });
33
+ describe("formatErrorDetailed", () => {
34
+ it("renders name, message and stack for a single error", () => {
35
+ const err = new Error("top");
36
+ const formatted = formatErrorDetailed(err);
37
+ expect(formatted).toContain("Error: top");
38
+ expect(formatted).toContain("at ");
39
+ });
40
+ it("walks the cause chain", () => {
41
+ const root = new Error("root failure");
42
+ const middle = new Error("middle", { cause: root });
43
+ const top = new Error("top", { cause: middle });
44
+ const formatted = formatErrorDetailed(top);
45
+ expect(formatted).toContain("Error: top");
46
+ expect(formatted).toContain("Caused by: Error: middle");
47
+ expect(formatted).toContain("Caused by: Error: root failure");
48
+ });
49
+ it("terminates when encountering a cycle in the cause chain", () => {
50
+ const a = new Error("a");
51
+ const b = new Error("b", { cause: a });
52
+ a.cause = b;
53
+ const formatted = formatErrorDetailed(a);
54
+ expect(formatted).toContain("Error: a");
55
+ expect(formatted).toContain("Caused by: Error: b");
56
+ });
57
+ it("handles non-Error causes gracefully", () => {
58
+ const err = new Error("wrapped", { cause: "raw string cause" });
59
+ const formatted = formatErrorDetailed(err);
60
+ expect(formatted).toContain("Error: wrapped");
61
+ expect(formatted).toContain("Caused by: raw string cause");
62
+ });
63
+ });
@@ -0,0 +1,92 @@
1
+ import { Command } from "commander";
2
+ import { describe, expect, it } from "vitest";
3
+ import { exitWithError, isVerbose } from "../index.js";
4
+ /**
5
+ * Builds a parent command that exposes the global `--verbose` flag so that
6
+ * `optsWithGlobals()` behaves the same way it does on the real CLI.
7
+ */
8
+ const makeProgramWith = (child, argv) => {
9
+ const program = new Command()
10
+ .name("dx")
11
+ .option("-v, --verbose", "verbose output", false)
12
+ .exitOverride()
13
+ .configureOutput({
14
+ writeErr: () => {
15
+ /* silence stderr in tests */
16
+ },
17
+ writeOut: () => {
18
+ /* silence stdout in tests */
19
+ },
20
+ });
21
+ child.exitOverride().configureOutput({
22
+ writeErr: () => {
23
+ /* silence */
24
+ },
25
+ writeOut: () => {
26
+ /* silence */
27
+ },
28
+ });
29
+ program.addCommand(child);
30
+ program.parse(argv, { from: "user" });
31
+ return program;
32
+ };
33
+ /**
34
+ * `exitWithError` always throws (Commander's `exitOverride()` converts the
35
+ * process.exit call into a CommanderError throw). This helper captures that
36
+ * throw so tests can assert on the thrown payload without putting `expect`
37
+ * inside a `catch` block (which vitest/no-conditional-expect disallows).
38
+ */
39
+ const captureThrown = (fn) => {
40
+ try {
41
+ fn();
42
+ }
43
+ catch (error) {
44
+ return error;
45
+ }
46
+ throw new Error("expected the callback to throw");
47
+ };
48
+ describe("isVerbose", () => {
49
+ it("is false when --verbose is not provided", () => {
50
+ const cmd = new Command("run").action(() => undefined);
51
+ makeProgramWith(cmd, ["run"]);
52
+ expect(isVerbose(cmd)).toBe(false);
53
+ });
54
+ it("is true when -v is provided at the root", () => {
55
+ const cmd = new Command("run").action(() => undefined);
56
+ makeProgramWith(cmd, ["-v", "run"]);
57
+ expect(isVerbose(cmd)).toBe(true);
58
+ });
59
+ it("is true when --verbose is provided at the root", () => {
60
+ const cmd = new Command("run").action(() => undefined);
61
+ makeProgramWith(cmd, ["--verbose", "run"]);
62
+ expect(isVerbose(cmd)).toBe(true);
63
+ });
64
+ });
65
+ describe("exitWithError", () => {
66
+ it("reports only the message in normal mode", () => {
67
+ const cmd = new Command("run").action(() => undefined);
68
+ makeProgramWith(cmd, ["run"]);
69
+ const err = new Error("outer", { cause: new Error("inner secret") });
70
+ expect(() => exitWithError(cmd)(err)).toThrow(/outer/);
71
+ const thrown = captureThrown(() => exitWithError(cmd)(err));
72
+ const message = String(thrown?.message ?? thrown);
73
+ expect(message).not.toContain("Caused by");
74
+ expect(message).not.toContain("inner secret");
75
+ });
76
+ it("includes the cause chain and stack trace in verbose mode", () => {
77
+ const cmd = new Command("run").action(() => undefined);
78
+ makeProgramWith(cmd, ["--verbose", "run"]);
79
+ const root = new Error("root cause");
80
+ const err = new Error("surface", { cause: root });
81
+ const thrown = captureThrown(() => exitWithError(cmd)(err));
82
+ const message = String(thrown?.message ?? thrown);
83
+ expect(message).toContain("Error: surface");
84
+ expect(message).toContain("Caused by: Error: root cause");
85
+ expect(message).toContain("at ");
86
+ });
87
+ it("works with non-Error values", () => {
88
+ const cmd = new Command("run").action(() => undefined);
89
+ makeProgramWith(cmd, ["run"]);
90
+ expect(() => exitWithError(cmd)("plain string failure")).toThrow(/plain string failure/);
91
+ });
92
+ });
@@ -0,0 +1,32 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ const mocks = vi.hoisted(() => ({
3
+ oraPromise: vi.fn((promise) => promise),
4
+ tf$: vi.fn(async (...args) => {
5
+ void args;
6
+ return { stdout: '{"user":{"name":"test@example.com"}}' };
7
+ }),
8
+ }));
9
+ vi.mock("ora", () => ({ oraPromise: mocks.oraPromise }));
10
+ vi.mock("../../../execa/terraform.js", () => ({ tf$: mocks.tf$ }));
11
+ import { checkAddEnvironmentPreconditions, checkInitPreconditions, } from "../init.js";
12
+ const calledCommands = () => mocks.tf$.mock.calls.map(([strings, ...values]) => strings.reduce((command, part, index) => command + part + String(values[index] ?? ""), ""));
13
+ describe("init preconditions", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+ it("checkInitPreconditions does not require Azure login", async () => {
18
+ const result = await checkInitPreconditions();
19
+ expect(result.isOk()).toBe(true);
20
+ expect(calledCommands()).toEqual(["terraform -version", "corepack -v"]);
21
+ });
22
+ it("checkAddEnvironmentPreconditions requires Azure login", async () => {
23
+ const result = await checkAddEnvironmentPreconditions();
24
+ expect(result.isOk()).toBe(true);
25
+ expect(calledCommands()).toEqual([
26
+ "terraform -version",
27
+ "az account show",
28
+ "az group list",
29
+ "corepack -v",
30
+ ]);
31
+ });
32
+ });
@@ -15,7 +15,8 @@ import { requestAuthorizationInputSchema, } from "../../../domain/authorization.
15
15
  import { environmentShort } from "../../../domain/environment.js";
16
16
  import { isAzureLocation, locationShort } from "../../azure/locations.js";
17
17
  import { getPlopInstance, runDeploymentEnvironmentGenerator, } from "../../plop/index.js";
18
- import { checkPreconditions } from "./init.js";
18
+ import { exitWithError } from "../index.js";
19
+ import { checkAddEnvironmentPreconditions } from "./init.js";
19
20
  /**
20
21
  * Authorize a Cloud Account (Azure Subscription, AWS Account, ...), creating a Pull Request for each account that requires authorization.
21
22
  */
@@ -69,9 +70,11 @@ const displaySummary = (result) => {
69
70
  }
70
71
  console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
71
72
  };
72
- const addEnvironmentAction = (authorizationService, gitHubService) => checkPreconditions()
73
- .andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
74
- .andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, gitHubService), () => new Error("Failed to run the deployment environment generator")))
73
+ const addEnvironmentAction = (authorizationService, gitHubService) => checkAddEnvironmentPreconditions()
74
+ .andThen(() => ResultAsync.fromPromise(getPlopInstance(), (cause) => new Error("Failed to initialize plop", { cause })))
75
+ .andThen((plop) => ResultAsync.fromPromise(runDeploymentEnvironmentGenerator(plop, gitHubService), (cause) => new Error("Failed to run the deployment environment generator", {
76
+ cause,
77
+ })))
75
78
  .andThen((payload) => authorizeCloudAccounts(authorizationService)(payload).map((authorizationPrs) => ({
76
79
  authorizationPrs,
77
80
  })));
@@ -83,7 +86,7 @@ export const makeAddCommand = (deps) => new Command()
83
86
  .action(async function () {
84
87
  const result = await addEnvironmentAction(deps.authorizationService, deps.gitHubService);
85
88
  if (result.isErr()) {
86
- this.error(result.error.message);
89
+ exitWithError(this)(result.error);
87
90
  }
88
91
  else {
89
92
  displaySummary(result.value);
@@ -1,5 +1,6 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import { Command } from "commander";
3
+ import { exitWithError } from "../index.js";
3
4
  export const makeCodemodCommand = ({ applyCodemodById, listCodemods, }) => new Command("codemod")
4
5
  .description("Manage and apply migration scripts to the repository")
5
6
  .addCommand(new Command("list")
@@ -7,7 +8,7 @@ export const makeCodemodCommand = ({ applyCodemodById, listCodemods, }) => new C
7
8
  .action(async function () {
8
9
  await listCodemods()
9
10
  .andTee((codemods) => console.table(codemods, ["id", "description"]))
10
- .orTee((error) => this.error(error.message));
11
+ .orTee(exitWithError(this));
11
12
  }))
12
13
  .addCommand(new Command("apply")
13
14
  .argument("<id>", "The id of the codemod to apply")
@@ -18,5 +19,5 @@ export const makeCodemodCommand = ({ applyCodemodById, listCodemods, }) => new C
18
19
  .andTee(() => {
19
20
  logger.info("Codemod applied ✅");
20
21
  })
21
- .orTee((error) => this.error(error.message));
22
+ .orTee(exitWithError(this));
22
23
  }));
@@ -1,7 +1,15 @@
1
1
  import { Command } from "commander";
2
2
  import { ResultAsync } from "neverthrow";
3
3
  import { GitHubService } from "../../../domain/github.js";
4
- export declare const checkPreconditions: () => ResultAsync<import("execa").Result<{
4
+ export declare const checkInitPreconditions: () => ResultAsync<import("execa").Result<{
5
+ environment: {
6
+ NO_COLOR: string;
7
+ TF_IN_AUTOMATION: string;
8
+ TF_INPUT: string;
9
+ };
10
+ shell: true;
11
+ }>, Error>;
12
+ export declare const checkAddEnvironmentPreconditions: () => ResultAsync<import("execa").Result<{
5
13
  environment: {
6
14
  NO_COLOR: string;
7
15
  TF_IN_AUTOMATION: string;
@@ -54,7 +54,8 @@ const ensureAzLogin = async () => {
54
54
  };
55
55
  const checkAzLogin = () => withSpinner("Check Azure login status...", (userName) => `You are logged in to Azure (${userName})`, "Please log in to Azure CLI using `az login` before running this command.", ensureAzLogin());
56
56
  // TODO(CES-1810): Make these checks concurrent to speed up the preconditions check phase
57
- export const checkPreconditions = () => checkTerraformCliIsInstalled()
57
+ export const checkInitPreconditions = () => checkTerraformCliIsInstalled().andThen(() => checkCorepackIsInstalled());
58
+ export const checkAddEnvironmentPreconditions = () => checkTerraformCliIsInstalled()
58
59
  .andThen(() => checkAzLogin())
59
60
  .andThen(() => checkCorepackIsInstalled());
60
61
  const createRemoteRepository = ({ repoName, repoOwner, }) => {
@@ -115,14 +116,14 @@ const handleGeneratorError = (err) => {
115
116
  if (err instanceof Error) {
116
117
  logger.error(err.message);
117
118
  }
118
- return new Error("Failed to run the generator");
119
+ return new Error("Failed to run the generator", { cause: err });
119
120
  };
120
121
  export const makeInitCommand = ({ gitHubService, }) => new Command()
121
122
  .name("init")
122
123
  .description("Initialize a new DX workspace")
123
124
  .action(async function () {
124
- await checkPreconditions()
125
- .andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
125
+ await checkInitPreconditions()
126
+ .andThen(() => ResultAsync.fromPromise(getPlopInstance(), (cause) => new Error("Failed to initialize plop", { cause })))
126
127
  .andThen((plop) => ResultAsync.fromPromise(runMonorepoGenerator(plop, gitHubService), handleGeneratorError))
127
128
  .andTee((payload) => {
128
129
  process.chdir(payload.repoName);
@@ -1,14 +1,15 @@
1
1
  import { azure, loadConfig } from "@pagopa/dx-savemoney";
2
2
  import { Command } from "commander";
3
+ import { exitWithError } from "../index.js";
3
4
  export const makeSavemoneyCommand = () => new Command("savemoney")
4
5
  .description("Analyze Azure subscriptions and report unused or inefficient resources")
5
6
  .option("-c, --config <path>", "Path to YAML configuration file")
6
7
  .option("-f, --format <format>", "Report format: json, table, detailed-json, or lint (default: table)", "table")
7
8
  .option("-l, --location <string>", "Preferred Azure location for resources (overrides config file)", "italynorth")
8
9
  .option("-d, --days <number>", "Number of days for metrics analysis (overrides config file)", "30")
9
- .option("-v, --verbose", "Enable verbose logging")
10
10
  .option("-t, --tags <tags...>", "Filter resources by tags (key=value key2=value2). Only resources matching ALL specified tags are analyzed.")
11
11
  .action(async function (options) {
12
+ const { verbose } = this.optsWithGlobals();
12
13
  try {
13
14
  // Load configuration from YAML (includes subscriptionIds, location, timespanDays, thresholds)
14
15
  const config = await loadConfig(options.config);
@@ -19,13 +20,15 @@ export const makeSavemoneyCommand = () => new Command("savemoney")
19
20
  filterTags,
20
21
  preferredLocation: options.location || config.preferredLocation,
21
22
  timespanDays: Number.parseInt(options.days, 10) || config.timespanDays,
22
- verbose: options.verbose || false,
23
+ verbose: verbose ?? false,
23
24
  };
24
25
  // Run analysis
25
26
  await azure.analyzeAzureResources(finalConfig, options.format);
26
27
  }
27
28
  catch (error) {
28
- this.error(`Analysis failed: ${error instanceof Error ? error.message : error}`);
29
+ exitWithError(this)(error instanceof Error
30
+ ? new Error(`Analysis failed: ${error.message}`, { cause: error })
31
+ : new Error(`Analysis failed: ${String(error)}`));
29
32
  }
30
33
  });
31
34
  /**
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Safely converts an unknown value to a human-readable error message.
3
+ * Preserves ExecaError.shortMessage when available and flattens AggregateError.
4
+ */
5
+ export declare const toErrorMessage: (value: unknown) => string;
6
+ /**
7
+ * Builds a detailed, multi-line representation of an error suitable for
8
+ * `--verbose` output: includes the full `cause` chain and stack traces.
9
+ */
10
+ export declare const formatErrorDetailed: (value: unknown) => string;
@@ -0,0 +1,68 @@
1
+ import { ExecaError } from "execa";
2
+ /**
3
+ * Safely converts an unknown value to a human-readable error message.
4
+ * Preserves ExecaError.shortMessage when available and flattens AggregateError.
5
+ */
6
+ export const toErrorMessage = (value) => {
7
+ if (value === null || value === undefined) {
8
+ return "Unknown error";
9
+ }
10
+ if (typeof value === "string") {
11
+ return value;
12
+ }
13
+ if (value instanceof ExecaError) {
14
+ return value.shortMessage || value.message || String(value);
15
+ }
16
+ if (value instanceof AggregateError) {
17
+ const parts = value.errors.map((inner) => toErrorMessage(inner));
18
+ return [value.message, ...parts].filter(Boolean).join("\n - ");
19
+ }
20
+ if (value instanceof Error) {
21
+ return value.message || value.name || "Error";
22
+ }
23
+ if (typeof value === "object") {
24
+ const maybeMessage = value.message;
25
+ if (typeof maybeMessage === "string" && maybeMessage.length > 0) {
26
+ return maybeMessage;
27
+ }
28
+ try {
29
+ return JSON.stringify(value);
30
+ }
31
+ catch {
32
+ return String(value);
33
+ }
34
+ }
35
+ return String(value);
36
+ };
37
+ /**
38
+ * Builds a detailed, multi-line representation of an error suitable for
39
+ * `--verbose` output: includes the full `cause` chain and stack traces.
40
+ */
41
+ export const formatErrorDetailed = (value) => {
42
+ const lines = [];
43
+ const seen = new Set();
44
+ let current = value;
45
+ let depth = 0;
46
+ while (current !== undefined && current !== null && !seen.has(current)) {
47
+ seen.add(current);
48
+ const prefix = depth === 0 ? "" : "Caused by: ";
49
+ if (current instanceof Error) {
50
+ lines.push(`${prefix}${current.name}: ${toErrorMessage(current)}`);
51
+ if (current.stack) {
52
+ // `stack` usually starts with "Name: message"; drop the first line to
53
+ // avoid duplication with the header we just printed.
54
+ const stackBody = current.stack.split("\n").slice(1).join("\n");
55
+ if (stackBody.trim().length > 0) {
56
+ lines.push(stackBody);
57
+ }
58
+ }
59
+ current = current.cause;
60
+ }
61
+ else {
62
+ lines.push(`${prefix}${toErrorMessage(current)}`);
63
+ current = undefined;
64
+ }
65
+ depth += 1;
66
+ }
67
+ return lines.join("\n");
68
+ };
@@ -3,5 +3,21 @@ import { Config } from "../../config.js";
3
3
  import { Dependencies } from "../../domain/dependencies.js";
4
4
  import { CodemodCommandDependencies } from "./commands/codemod.js";
5
5
  export type CliDependencies = CodemodCommandDependencies;
6
+ export type GlobalOptions = {
7
+ verbose?: boolean;
8
+ };
9
+ /**
10
+ * Returns true when the global `--verbose` flag is active on the closest
11
+ * ancestor command that defines it (the root `dx` program in our CLI).
12
+ */
13
+ export declare const isVerbose: (command: Command) => boolean;
6
14
  export declare const makeCli: (deps: Dependencies, config: Config, cliDeps: CliDependencies, version: string) => Command;
7
- export declare const exitWithError: (command: Command) => (error: Error) => never;
15
+ /**
16
+ * Builds a failure handler that ends the command via Commander's
17
+ * `Command#error`, with an output tailored to the active verbosity.
18
+ *
19
+ * - In normal mode, a single meaningful line is printed.
20
+ * - When `--verbose` is active, the full cause chain and stack trace are
21
+ * included so users can diagnose the underlying failure.
22
+ */
23
+ export declare const exitWithError: (command: Command) => (error: unknown) => never;
@@ -5,9 +5,19 @@ import { makeDoctorCommand } from "./commands/doctor.js";
5
5
  import { makeInfoCommand } from "./commands/info.js";
6
6
  import { makeInitCommand } from "./commands/init.js";
7
7
  import { makeSavemoneyCommand } from "./commands/savemoney.js";
8
+ import { formatErrorDetailed, toErrorMessage } from "./error-reporting.js";
9
+ /**
10
+ * Returns true when the global `--verbose` flag is active on the closest
11
+ * ancestor command that defines it (the root `dx` program in our CLI).
12
+ */
13
+ export const isVerbose = (command) => command.optsWithGlobals().verbose === true;
8
14
  export const makeCli = (deps, config, cliDeps, version) => {
9
15
  const program = new Command();
10
- program.name("dx").description("The CLI for DX-Platform").version(version);
16
+ program
17
+ .name("dx")
18
+ .description("The CLI for DX-Platform")
19
+ .version(version)
20
+ .option("-v, --verbose", "Enable verbose output: debug-level logs and full error chain (with stack traces) when a command fails", false);
11
21
  program.addCommand(makeDoctorCommand(deps, config));
12
22
  program.addCommand(makeCodemodCommand(cliDeps));
13
23
  program.addCommand(makeInitCommand(deps));
@@ -16,6 +26,17 @@ export const makeCli = (deps, config, cliDeps, version) => {
16
26
  program.addCommand(makeAddCommand(deps));
17
27
  return program;
18
28
  };
29
+ /**
30
+ * Builds a failure handler that ends the command via Commander's
31
+ * `Command#error`, with an output tailored to the active verbosity.
32
+ *
33
+ * - In normal mode, a single meaningful line is printed.
34
+ * - When `--verbose` is active, the full cause chain and stack trace are
35
+ * included so users can diagnose the underlying failure.
36
+ */
19
37
  export const exitWithError = (command) => (error) => {
20
- command.error(error.message);
38
+ const message = isVerbose(command)
39
+ ? formatErrorDetailed(error)
40
+ : toErrorMessage(error);
41
+ command.error(message);
21
42
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for the internal `runActions` helper that coordinates plop's
3
+ * generator execution and surfaces meaningful error messages (CES-1923).
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { mock } from "vitest-mock-extended";
7
+ import { runActions } from "../index.js";
8
+ const makeGenerator = (result) => {
9
+ const generator = mock();
10
+ generator.runActions.mockResolvedValue(result);
11
+ return generator;
12
+ };
13
+ describe("runActions", () => {
14
+ it("resolves silently when there are no failures", async () => {
15
+ const generator = makeGenerator({ changes: [], failures: [] });
16
+ await expect(runActions(generator, {})).resolves.toBeUndefined();
17
+ });
18
+ it("ignores 'Aborted due to previous action failure' entries", async () => {
19
+ const generator = makeGenerator({
20
+ changes: [],
21
+ failures: [
22
+ {
23
+ error: "Aborted due to previous action failure",
24
+ path: "",
25
+ type: "add",
26
+ },
27
+ ],
28
+ });
29
+ await expect(runActions(generator, {})).resolves.toBeUndefined();
30
+ });
31
+ it("surfaces the original failure message (no more 'undefined')", async () => {
32
+ const generator = makeGenerator({
33
+ changes: [],
34
+ failures: [
35
+ {
36
+ error: "Failed to create the key vault: quota exceeded",
37
+ path: "",
38
+ type: "initCloudAccounts",
39
+ },
40
+ ],
41
+ });
42
+ await expect(runActions(generator, {})).rejects.toThrow(/initCloudAccounts.*Failed to create the key vault: quota exceeded/);
43
+ });
44
+ it("aggregates multiple failures into the thrown message", async () => {
45
+ const generator = makeGenerator({
46
+ changes: [],
47
+ failures: [
48
+ { error: "Missing template", path: "", type: "add" },
49
+ {
50
+ error: "Aborted due to previous action failure",
51
+ path: "",
52
+ type: "modify",
53
+ },
54
+ { error: "Permission denied", path: "", type: "initCloudAccounts" },
55
+ ],
56
+ });
57
+ await expect(runActions(generator, {})).rejects.toThrow(/add: Missing template.*initCloudAccounts: Permission denied/s);
58
+ });
59
+ it("falls back to 'unknown error' when plop provides no error string", async () => {
60
+ const generator = makeGenerator({
61
+ changes: [],
62
+ failures: [
63
+ { error: undefined, path: "", type: "add" },
64
+ ],
65
+ });
66
+ await expect(runActions(generator, {})).rejects.toThrow(/unknown error/);
67
+ });
68
+ });
@@ -1,10 +1,12 @@
1
- import type { NodePlopAPI } from "plop";
1
+ import type { NodePlopAPI, PlopGenerator } from "plop";
2
+ import { Answers } from "inquirer";
2
3
  import { GitHubRepo } from "../../domain/github-repo.js";
3
4
  import { GitHubService } from "../../domain/github.js";
4
5
  import { Payload as EnvironmentPayload } from "../plop/generators/environment/index.js";
5
6
  import { Payload as MonorepoPayload } from "../plop/generators/monorepo/index.js";
6
7
  export declare const setMonorepoGenerator: (plop: NodePlopAPI) => void;
7
8
  export declare const getPlopInstance: () => Promise<NodePlopAPI>;
9
+ export declare const runActions: (generator: PlopGenerator, payload: Answers) => Promise<void>;
8
10
  export declare const runMonorepoGenerator: (plop: NodePlopAPI, githubService: GitHubService) => Promise<MonorepoPayload>;
9
11
  /**
10
12
  * Run the deployment environment generator
@@ -26,19 +26,27 @@ const validatePayload = async (payload, github) => {
26
26
  }
27
27
  };
28
28
  export const getPlopInstance = async () => nodePlop();
29
- const runActions = async (generator, payload) => {
29
+ export const runActions = async (generator, payload) => {
30
30
  const logger = getLogger(["dx-cli", "init"]);
31
31
  const result = await generator.runActions(payload);
32
32
  if (result.failures.length > 0) {
33
- for (const failure of result.failures) {
34
- if (failure.error === "Aborted due to previous action failure") {
35
- continue;
36
- }
37
- logger.error(`Error on {type} step. ${failure.message}`, {
38
- type: failure.type,
33
+ // Collect every failure to report rich context. node-plop's failure
34
+ // objects have shape `{ type, path, error }` (the `error` property holds
35
+ // the original error's message — see node-plop's generator-runner.js).
36
+ const relevant = result.failures.filter((failure) => failure.error !== "Aborted due to previous action failure");
37
+ if (relevant.length === 0) {
38
+ return;
39
+ }
40
+ const summary = relevant
41
+ .map((failure) => `${failure.type || "action"}: ${failure.error ?? "unknown error"}`)
42
+ .join("; ");
43
+ for (const failure of relevant) {
44
+ logger.error("Error on {type} step: {error}", {
45
+ error: failure.error ?? "unknown error",
46
+ type: failure.type || "action",
39
47
  });
40
- throw new Error("One or more actions failed during generation.");
41
48
  }
49
+ throw new Error(`One or more actions failed during generation (${summary}).`);
42
50
  }
43
51
  };
44
52
  export const runMonorepoGenerator = async (plop, githubService) => {
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import "core-js/actual/set/index.js";
2
+ import { configure, getConsoleSink } from "@logtape/logtape";
2
3
  import * as assert from "node:assert/strict";
3
4
  import { Octokit } from "octokit";
4
5
  import codemodRegistry from "./adapters/codemods/index.js";
@@ -13,7 +14,42 @@ import { getInfo } from "./domain/info.js";
13
14
  import { applyCodemodById } from "./use-cases/apply-codemod.js";
14
15
  import { listCodemods } from "./use-cases/list-codemods.js";
15
16
  import { requestAuthorization } from "./use-cases/request-authorization.js";
17
+ /**
18
+ * Returns `true` when `-v` or `--verbose` is present in argv.
19
+ *
20
+ * We inspect argv directly — instead of relying on Commander — because the
21
+ * logtape configuration must be in place before any command handler runs
22
+ * (including the ones that emit debug logs while parsing prompts).
23
+ */
24
+ const detectVerboseFromArgv = (argv) => argv.includes("-v") || argv.includes("--verbose");
25
+ const configureLogging = async (verbose) => {
26
+ const level = verbose ? "debug" : "info";
27
+ await configure({
28
+ loggers: [
29
+ { category: ["dx-cli"], lowestLevel: level, sinks: ["console"] },
30
+ // The environment generator (`gen.env`) emits debug messages about
31
+ // provisioned Azure resources; surfacing them is the main value of
32
+ // `--verbose` when running `dx init` / `dx add environment`.
33
+ { category: ["gen"], lowestLevel: level, sinks: ["console"] },
34
+ // `savemoney` already emits structured debug output by default.
35
+ { category: ["savemoney"], lowestLevel: "debug", sinks: ["console"] },
36
+ { category: ["json"], lowestLevel: "info", sinks: ["rawJson"] },
37
+ {
38
+ category: ["logtape", "meta"],
39
+ lowestLevel: "warning",
40
+ sinks: ["console"],
41
+ },
42
+ ],
43
+ sinks: {
44
+ console: getConsoleSink(),
45
+ rawJson(record) {
46
+ console.log(record.rawMessage);
47
+ },
48
+ },
49
+ });
50
+ };
16
51
  export const runCli = async (version) => {
52
+ await configureLogging(detectVerboseFromArgv(process.argv));
17
53
  // Creating the adapters
18
54
  const repositoryReader = makeRepositoryReader();
19
55
  const packageJsonReader = makePackageJsonReader();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "@azure/identity": "^4.13.1",
32
32
  "@azure/keyvault-secrets": "^4.11.1",
33
33
  "@azure/storage-blob": "^12.31.0",
34
- "@logtape/logtape": "^1.3.7",
34
+ "@logtape/logtape": "^1.3.8",
35
35
  "@microsoft/microsoft-graph-client": "^3.0.7",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
@@ -39,16 +39,16 @@
39
39
  "execa": "^9.6.1",
40
40
  "glob": "^11.1.0",
41
41
  "inquirer": "^9.3.8",
42
- "libsodium-wrappers": "^0.8.2",
42
+ "libsodium-wrappers": "^0.8.4",
43
43
  "neverthrow": "^8.2.0",
44
44
  "node-plop": "^0.32.3",
45
45
  "octokit": "^5.0.5",
46
- "ora": "^9.3.0",
46
+ "ora": "^9.4.0",
47
47
  "replace-in-file": "^8.4.0",
48
48
  "semver": "^7.7.4",
49
- "yaml": "^2.8.3",
50
- "zod": "^4.3.6",
51
- "@pagopa/dx-savemoney": "^0.2.4"
49
+ "yaml": "^2.8.4",
50
+ "zod": "^4.4.2",
51
+ "@pagopa/dx-savemoney": "^0.2.5"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@tsconfig/node24": "24.0.4",
@@ -57,14 +57,14 @@
57
57
  "@types/node": "^22.19.17",
58
58
  "@types/semver": "^7.7.1",
59
59
  "@vitest/coverage-v8": "^3.2.4",
60
- "eslint": "^10.2.0",
61
- "memfs": "^4.57.1",
60
+ "eslint": "^10.3.0",
61
+ "memfs": "^4.57.2",
62
62
  "plop": "^4.0.5",
63
63
  "prettier": "3.8.3",
64
64
  "typescript": "~5.9.3",
65
65
  "vitest": "^3.2.4",
66
66
  "vitest-mock-extended": "^3.1.1",
67
- "@pagopa/eslint-config": "^6.0.3"
67
+ "@pagopa/eslint-config": "^6.0.4"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=22.0.0"
@@ -9,6 +9,7 @@ on:
9
9
 
10
10
  permissions:
11
11
  contents: read
12
+ id-token: write
12
13
 
13
14
  jobs:
14
15
  release:
@@ -2,4 +2,5 @@
2
2
  **/*.hbs
3
3
 
4
4
  # Ignore built files
5
- dist
5
+ dist
6
+ CHANGELOG.md