@pagopa/dx-cli 0.20.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/index.js +0 -20
- package/dist/adapters/commander/__tests__/error-reporting.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/error-reporting.test.js +63 -0
- package/dist/adapters/commander/__tests__/exit-with-error.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/exit-with-error.test.js +92 -0
- package/dist/adapters/commander/commands/add.js +6 -3
- package/dist/adapters/commander/commands/codemod.js +3 -2
- package/dist/adapters/commander/commands/init.js +2 -2
- package/dist/adapters/commander/commands/savemoney.js +6 -3
- package/dist/adapters/commander/error-reporting.d.ts +10 -0
- package/dist/adapters/commander/error-reporting.js +68 -0
- package/dist/adapters/commander/index.d.ts +17 -1
- package/dist/adapters/commander/index.js +23 -2
- package/dist/adapters/plop/__tests__/run-actions.test.d.ts +1 -0
- package/dist/adapters/plop/__tests__/run-actions.test.js +68 -0
- package/dist/adapters/plop/index.d.ts +3 -1
- package/dist/adapters/plop/index.js +16 -8
- package/dist/index.js +36 -0
- package/package.json +2 -2
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));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
|
@@ -15,6 +15,7 @@ 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 { exitWithError } from "../index.js";
|
|
18
19
|
import { checkPreconditions } 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.
|
|
@@ -70,8 +71,10 @@ const displaySummary = (result) => {
|
|
|
70
71
|
console.log(`${step}. Visit ${chalk.underline("https://dx.pagopa.it/getting-started")} to deploy your first project\n`);
|
|
71
72
|
};
|
|
72
73
|
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"
|
|
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
|
|
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((
|
|
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((
|
|
22
|
+
.orTee(exitWithError(this));
|
|
22
23
|
}));
|
|
@@ -115,14 +115,14 @@ const handleGeneratorError = (err) => {
|
|
|
115
115
|
if (err instanceof Error) {
|
|
116
116
|
logger.error(err.message);
|
|
117
117
|
}
|
|
118
|
-
return new Error("Failed to run the generator");
|
|
118
|
+
return new Error("Failed to run the generator", { cause: err });
|
|
119
119
|
};
|
|
120
120
|
export const makeInitCommand = ({ gitHubService, }) => new Command()
|
|
121
121
|
.name("init")
|
|
122
122
|
.description("Initialize a new DX workspace")
|
|
123
123
|
.action(async function () {
|
|
124
124
|
await checkPreconditions()
|
|
125
|
-
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), () => new Error("Failed to initialize plop")))
|
|
125
|
+
.andThen(() => ResultAsync.fromPromise(getPlopInstance(), (cause) => new Error("Failed to initialize plop", { cause })))
|
|
126
126
|
.andThen((plop) => ResultAsync.fromPromise(runMonorepoGenerator(plop, gitHubService), handleGeneratorError))
|
|
127
127
|
.andTee((payload) => {
|
|
128
128
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A CLI useful to manage DX tools.",
|
|
6
6
|
"repository": {
|
|
@@ -57,7 +57,7 @@
|
|
|
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.
|
|
60
|
+
"eslint": "^10.2.1",
|
|
61
61
|
"memfs": "^4.57.1",
|
|
62
62
|
"plop": "^4.0.5",
|
|
63
63
|
"prettier": "3.8.3",
|